Reputation: 350
I have a slice like this:
const authSlice = createSlice({
name: "auth",
initialState: {
...initialUserInfo,
...initialBasicAsyncState,
},
reducers: {
setUser: (state, { payload }: PayloadAction<{ userObj: User }>) => {
const { id, email, googleId, facebookId } = payload.userObj;
state.id = id;
state.email = email;
if (googleId) state.googleId = googleId;
if (facebookId) state.facebookId = facebookId;
},
clearUser: (state) => {
state.id = "";
state.email = "";
state.googleId = "";
state.facebookId = "";
},
},
extraReducers: (builder) =>
builder
.addCase(getUser.pending, (state, action) => {
state.isLoading = true;
})
.addCase(getUser.fulfilled, (state, action) => {
state.isLoading = false;
state.isSuccess = true;
state.errors = null;
})
.addCase(getUser.rejected, (state, action) => {
state.isLoading = false;
if (action.payload) {
state.errors = action.payload;
} else if (action.error) state.errors = action.error;
}),
});
I would like to write a reusable piece of code for the part which begins in the extraReducers
, because I will have same async request handling in several slices. I read the docs, but still did not manage to understand how to accomplish this.
Upvotes: 4
Views: 2250
Reputation: 42278
There is an example in the docs which shows how to use addMatcher
with type predicates like action.type.endsWith('/pending')
to match any pending action.
I played with this a bit and one of the things that makes it hard is that the builder
functions need to be called in a particular order: addCase
, then addMatcher
, then addDefaultCase
. So we cannot apply a bunch of addMatcher
calls first and then use the builder
normally.
The other hard part is knowing the payload
type for rejectWithValue
since the rejection value is not one of the generics on the AsyncThunk
type (at least not directly).
The best solution that I came up with is to use a single addMatcher
call to handle all three cases of the thunk.
These basic types and type guards are copied from the docs.
type GenericAsyncThunk = AsyncThunk<unknown, unknown, any>
type PendingAction = ReturnType<GenericAsyncThunk['pending']>
type RejectedAction = ReturnType<GenericAsyncThunk['rejected']>
type FulfilledAction = ReturnType<GenericAsyncThunk['fulfilled']>
function isPendingAction(action: AnyAction): action is PendingAction {
return action.type.endsWith('/pending')
}
function isRejectedAction(action: AnyAction): action is RejectedAction {
return action.type.endsWith('/rejected')
}
function isFulfilledAction(action: AnyAction): action is FulfilledAction {
return action.type.endsWith('/fulfilled')
}
The action matcher is a curried function that takes 1-to-many thunks and returns true
if the action name starts with the typePrefix
of any of those thunks.
const isThunk = <T extends AsyncThunk<any, any, any>[]>(...thunks: T) =>
(action: AnyAction) =>
thunks.some((thunk) => action.type.startsWith(thunk.typePrefix));
The case reducer handles all three cases of the thunk(s). It calls the type guard functions to determine which type the action
is and update the state accordingly. We require that the state
extends a type with the properties that we are updating.
type BasicAsyncState = {
isLoading: boolean;
isSuccess: boolean;
errors: any;
};
const thunkHandler = <S extends BasicAsyncState>(
state: Draft<S>,
action: AnyAction
): void => {
if (isPendingAction(action)) {
state.isLoading = true;
} else if (isFulfilledAction(action)) {
state.isLoading = false;
state.isSuccess = true;
state.errors = null;
} else if (isRejectedAction(action)) {
state.isSuccess = false;
state.isLoading = false;
state.errors = action.error;
}
};
You would use these two functions in the same block as your other builder callbacks. Remember that addMatcher
always needs to come after addCase
.
// can match one thunk
.addMatcher(isThunk(getUser), thunkHandler)
// or multiple thunks
.addMatcher(isThunk(getUser, loadSomething), thunkHandler)
Upvotes: 1
Reputation: 3975
interface CustomState {
isLoading: boolean
isSuccess: boolean
errors: ValidationErrors | SerializedError | null
}
function apiReducerBuilder<T, U>(
builder: ActionReducerMapBuilder<CustomState>,
customThunk: AsyncThunk<
T,
U,
{
rejectValue: ValidationErrors
}
>
) {
return builder
.addCase(customThunk.pending, (state) => {
state.isLoading = true
})
.addCase(customThunk.fulfilled, (state) => {
state.isLoading = false
state.isSuccess = true
state.errors = null
})
.addCase(customThunk.rejected, (state, action) => {
state.isLoading = false
if (action.payload) {
state.errors = action.payload
} else if (action.error) state.errors = action.error
})
}
Usage:
extraReducers: (builder) => apiReducerBuilder(builder, getUser)
This will only work if you pass the same state type Custom State
. And your typing are well established for the thunk. Or else you will have to declare the types when calling apiReducerBuilder
So, make sure that you always pass the same Custom State
to your builder and Thunk or else it will not work.
EDIT
Maybe, you can extend your slice state type with Custom State type and it might just work. I haven't tried it. So, I'll leave it to you to see if it works.
Upvotes: 2