How to reuse the reducer logic in Redux Toolkit createSlice function?

Viewed 6089

I'm new to React and I'm learning to use React to build a web app. I found Redux Toolkit useful and use its createSlice() function to implement the basic features. However, I encountered a "best practice"-related problem and I'm not sure whether I've built the architecture of the app correctly.

Let's say I have a user object stored in Redux. I created an async thunk function to fetch related information:

export const getUserInfo = createAsyncThunk('user/get', async (userId, thunkApi) => {
    // fetching information using api
}

Correspondingly, I handled the pending/fulfilled/rejected callback as follows:

const userSlice = createSlice({
    name: 'user',
    initialState,
    reducers: {
        setShowProgress(state, action: PayloadAction<boolean>) {
            state.showProgress = action.payload;
        },
        clearError(state) {
            state.error = null;
            state.errorMessage = null;
        }
    },
    extraReducers: builder => {
        builder.addCase(getUserInfo.pending, (state, action) => {
            // My question is here >_<
        }
        builder.addCase(getUserInfo.fulfilled, (state, action) => {
            // handle data assignments
        })
        builder.addCase(getUserInfo.rejected, (state, action) => {
            // handle error messages
        })
    }
})

Considering modifying display status flags are quite common in other feature api implementations, I wrapped the two functions (setShowProgress() and clearError()) in reducers. Here comes my question: How can I reference the two functions in getUserInfo.pending function?

Though I could just assign the showProgress and error state variables in getUserInfo.pending instead of trying to call the reducer functions, that will definitely introduce duplicate code when I implement other fetching actions in the future. If it is not the recommended pattern, what is the best practice for this scenario?

2 Answers

If your goal is just to set a loading boolean or error property based on pending/fulfilled/rejected, you can just use addMatcher which was introduced in version 1.4.

Here is a very basic example of using generic helpers to make this simple across many slices.

// First, we'll just create some helpers in the event you do this in other slices. I'd export these from a util.

const hasPrefix = (action: AnyAction, prefix: string) =>
  action.type.startsWith(prefix);
const isPending = (action: AnyAction) => action.type.endsWith("/pending");
const isFulfilled = (action: AnyAction) => action.type.endsWith("/fulfilled");
const isRejected = (action: AnyAction) => action.type.endsWith("/rejected");

const isPendingAction = (prefix: string) => (
  action: AnyAction
): action is AnyAction => { // Note: this cast to AnyAction could also be `any` or whatever fits your case best
  return hasPrefix(action, prefix) && isPending(action);
};

const isRejectedAction = (prefix: string) => (
  action: AnyAction
): action is AnyAction => { // Note: this cast to AnyAction could also be `any` or whatever fits your case best - like if you had standardized errors and used `rejectWithValue`
  return hasPrefix(action, prefix) && isRejected(action);
};

const isFulfilledAction = (prefix: string) => (
  action: AnyAction
): action is AnyAction => {
  return hasPrefix(action, prefix) && isFulfilled(action);
};

const userSlice = createSlice({
    name: 'user',
    initialState,
    reducers: {},
    extraReducers: builder => {
      builder.addCase(getUserInfo.fulfilled, (state, action) => {
          // handle data assignments
      })
      // use scoped matchers to handle generic loading / error setting behavior for async thunks this slice cares about
      .addMatcher(isPendingAction("user/"), state => {
        state.loading = true;
        state.error = '';
      })
      .addMatcher(isRejectedAction("user/"), (state, action) => {
        state.loading = false;
        state.error = action.error; // or you could use `rejectWithValue` and pull it from the payload.
      })
      .addMatcher(isFulfilledAction("user/"), state => {
        state.loading = false;
        state.error = '';
      });
    }
})

I ran into a similar problem when replacing the old switch case approach with the new one using createReducer from redux-toolkit.

In the old approach, I often had a common reducer for many actions as shown below:

const authentication = (state = initialState, action: AuthenticationActions) => {
  switch (action.type) {
    case SIGN_UP_SUCCESS:
    case SIGN_IN_SUCCESS:
      return {
        ...state,
        account: action.payload,
      };
    case SIGN_OUT_SUCCESS:
      return initialState;
    default:
      return state;
  }
};

In the new approach, I would have to create two identical reducers and use them in .addCase. So I created a universal helper to match multiple actions:

import { Action, AnyAction } from '@reduxjs/toolkit';

declare interface TypedActionCreator<Type extends string> {
  (...args: any[]): Action<Type>;
  type: Type;
}
const isOneOf = <ActionCreator extends TypedActionCreator<string>>(actions: ActionCreator[]) => (
  (action: AnyAction): action is ReturnType<ActionCreator> => (
    actions.map(({ type }) => type).includes(action.type)
  )
);

And now we can easily use it with .addMatcher:

const authentication = createReducer(initialState, (builder) => (
  builder
    .addCase(signOutSuccess, () => ({
      ...initialState,
    }))
    .addMatcher(isOneOf([signInSuccess, signUpSuccess]), (state, action) => ({
      ...state,
      account: action.payload,
    }))
));
Related