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?
A function that accepts an initial state, an object of reducer functions, and a "slice name", and automatically generates action creators and action types that correspond to the reducers and state. This API is the standard approach for writing Redux logic.
Most certainly! Reducers should be pure functions, so the logic must be pure as well.
extraReducers One of the key concepts of Redux is that each slice reducer "owns" its slice of state, and that many slice reducers can independently respond to the same action type. extraReducers allows createSlice to respond to other action types besides the types it has generated.
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,
}))
));
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With