Redux Thunk Best Practices for Loading Initial Data

Every application needs to handle a couple of tasks: loading initial data for users and determining what page or screen they land on. Despite being order-dependent, this logic is often scattered into various parts of the application. This can lead to subtle bugs and makes it hard to answer simple questions like, “What will the user see when they log in?”

Redux and Redux Thunk are great for structuring front-end logic, but without a strategy for handling loading, the logic can still get out of control.

Imagine an app where a user can log in, accept the terms and conditions, accept the privacy policy, and complete a tutorial. Although the actions themselves may be completed out of order, there is a certain order for checking them and determining the landing screen.

For example, a user who previously accepted the terms and conditions may not be currently logged in. This person would initially see the login screen. After logging in, they should skip past the terms and conditions screen and see the privacy policy screen. Once they accept those terms, they are taken to the tutorial screen.

Although reasonably simple, there is already potential for unnecessary code duplication. The logic that takes the user from the login screen to the privacy policy screen should be the same from the loading screen. Once fetching data from the server is mixed in, complexity can easily get out of hand.

A fundamental principle of this strategy is to enforce separation between fetching data, determining the landing page, and navigating to it.

If we translated just the landing page determination into code, it might look something like this:


function nextScreen(user: User): string {
  if (!user.loggedIn) {
    return 'LoginScreen';
  }

  if (!user.acceptedTermsAndConditions) {
    return 'TermsAndConditionsScreen';
  }

  if (!user.acceptedPrivacyPolicy) {
    return 'PrivacyPolicyScreen';
  }

  if (!user.completedTutorial) {
    return 'TutorialScreen';
  }

  return 'HomeScreen';
}

By simply looking in this one function, we can say where any user will land.

Now, let’s take this function and adjust it using the ReduxState.


// app/reducers/nextScreenNavigationHelper.ts
import { ReduxState } from '../reducers';


export function nextScreen(state: ReduxState): string {
  if (!state.user.username) {
    return 'LoginScreen';
  }

  if (!state.user.acceptedTermsAndConditions) {
    return 'TermsAndConditionsScreen';
  }

  if (!state.user.acceptedPrivacyPolicy) {
    return 'PrivacyPolicyScreen';
  }

  if (!state.user.completedTutorial) {
    return 'TutorialScreen';
  }

  return 'HomeScreen';
}

Here, we assume full access to everything in the Redux state without worrying about how it was fetched.

I revisited the abstractions from my pattern for Redux Thunk async actions in the context of navigation and modified the async Thunk action creator to support success and failure navigation.


// app/actions/asyncAction.ts
export function async<T, P, S>(type: T,
                               action: () => Promise<P>,
                               successNavigation?: (state: S) => void,
                               failureNavigation?: (state: S) => void): ThunkAction<Promise<void>, S, void, AsyncAction<T, P>> {
  return async (dispatch, getState) => {
    dispatch(startedAsyncAction(type));
    try {
      const payload = await action();
      dispatch(succeededAsyncAction(type, payload));
      if (successNavigation) {
        successNavigation(getState());
      }
    } catch (error) {
      dispatch(failedAsyncAction(type, error));
      if (failureNavigation) {
        failureNavigation(getState());
      }
    }
  };
}

S, the third generic, represents the ReduxState. By not referencing ReduxState directly, we avoid leaking application code into this pure abstraction. Both navigation callbacks are optional, and they are only called if provided to avoid unnecessary getState calls.

We can now modify loadAction to pass in a success navigation callback.


// app/actions/loadAction.ts
import { async, AsyncAction } from './asyncAction';
import { ReduxState } from '../reducers';
import { LoadingData, loadingService } from '../services/loadingService';
import { nextScreen } from './nextScreenNavigationHelper';

export const LOAD = 'LOAD';
export type LoadAction = AsyncAction<typeof LOAD, LoadingData>;

export function loadAction(navigate: (routeName: string) => void) {
  return async(LOAD, loadingService.load, (state: ReduxState) => navigate(nextScreen(state)));
}

Most React navigation libraries have a function that can be called to navigate to specific pages or screens. React Router web has history.push and React Navigation has navigation.navigate. We can pass that function as the navigate argument at dispatch time.


// app/containers/LoadingScreen.ts
import { NavigationScreenProps } from 'react-navigation';
import { connect } from 'react-redux';
import { ThunkDispatch } from 'redux-thunk';
import { LoadAction, loadAction } from '../actions/loadAction';
import LoadingScreen, { Props } from '../components/LoadingScreen';
import { ReduxState } from '../reducers';

type OwnProps = NavigationScreenProps<{}>;
type StateProps = Pick<Props, 'loadStatus'>;
type DispatchProps = Pick<Props, 'load'>;

function mapStateToProps(state: ReduxState): StateProps {
  return {
    loadStatus: state.loadStatus,
  };
}

function mapDispatchToProps(dispatch: ThunkDispatch<ReduxState, void, LoadAction>, ownProps: OwnProps): DispatchProps {
  return {
    load: () => dispatch(loadAction(ownProps.navigation.navigate)),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(LoadingScreen);

This neatly abstracts our choice of navigation library away from the actions.

We now circle around to the fetching aspect by filling out loadingService. Although there may be significant logic to hydrate the Redux state, none of it pertains to figuring out where the user should land and how to navigate them there.


// app/services/loadingService.ts
import { userService } from './userService';
import { secureStorage } from '../secureStorage';


export interface LoadingData {
  username: string | null;
  acceptedTermsAndConditions: boolean;
  acceptedPrivacyPolicy: boolean;
  completedTutorial: boolean;
}

async function load(): Promise>LoadingData< {
  const username = await secureStorage.get('USERNAME');

  if (!username) {
    return {
      username: null,
      acceptedTermsAndConditions: false,
      acceptedPrivacyPolicy: false,
      completedTutorial: false,
    };
  }

  const acceptedTermsAndConditions = await userService.hasAcceptedTermsAndConditions();
  const acceptedPrivacyPolicy = await userService.hasAcceptedPrivacyPolicy();
  const completedTutorial = await userService.hasCompletedTutorial();

  return {
    username,
    acceptedTermsAndConditions,
    acceptedPrivacyPolicy,
    completedTutorial,
  };
}

export const loadingService = {
  load,
};

This strategy really helps when handling situations like what’s described above. Since nextScreen is a pure function of ReduxState and does not make any asynchronous calls, logInAction can call it without fear.


// logInAction.ts
import { async, AsyncAction } from 'src/actions/asyncAction';
import { ReduxState } from '../reducers';
import { User, userService } from '../services/userService';
import { nextScreen } from './nextScreenNavigationHelper';


export const LOG_IN = 'LOG_IN';
export type LogInAction = AsyncAction<typeof LOG_IN, User>;

export function logInAction(username: string, password: string, navigate: (routeName: string) => void) {
  return async(LOG_IN, () => userService.logIn(username, password), (state: ReduxState) => navigate(nextScreen(state)));
}

Likewise, LoginScreen can dispatch a logInAction and expect navigation to behave in a sane manner.


// app/containers/LoginScreen.ts
import { NavigationScreenProps } from 'react-navigation';
import { connect } from 'react-redux';
import { ThunkDispatch } from 'redux-thunk';
import { LogInAction, logInAction } from '../actions/logInAction';
import LoginScreen, { Props } from '../components/LoginScreen';
import { ReduxState } from '../reducers';

type OwnProps = NavigationScreenProps<{}>;
type StateProps = Pick<Props, 'logInStatus'>;
type DispatchProps = Pick<Props, 'logIn'>;

function mapStateToProps(state: ReduxState): StateProps {
  return {
    logInStatus: state.user.logInStatus,
  };
}

function mapDispatchToProps(dispatch: ThunkDispatch<ReduxState, void, LogInAction>, ownProps: OwnProps): DispatchProps {
  return {
    logIn: (username, password) => dispatch(logInAction(username, password, ownProps.navigation.navigate)),
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(LoginScreen);

Hopefully you find this strategy helpful for loading your users consistently and preserving your sanity.

Conversation
  • Bess says:

    Thanks for the great article! It helped me wrap my head around the TS integration with React/Redux/Thunk quite a bit. Do you happen to have a Github repo with this tutorial? Thanks!

    • Tom Liao Tom Liao says:

      Unfortunately, I don’t have a runnable Github repo with this tutorial. Sorry!

  • Frank Leon Rose says:

    Thanks for the code!

    Rather than serializing the requests to the `userService` (await, await, await), you can parallelize them like this:

    const [acceptedTermsAndConditions, acceptedPrivacyPolicy, completedTutorial] =
    await Promise.all([userService.hasAcceptedTermsAndConditions(),
    userService.hasAcceptedPrivacyPolicy(), userService.hasCompletedTutorial()]);

  • Comments are closed.