KestVir
KestVir

Reputation: 350

How to reuse reducers in redux toolkit with typescript?

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

Answers (2)

Linda Paiste
Linda Paiste

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)

Typescript Playground Link

Upvotes: 1

Tom Bombadil
Tom Bombadil

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

Related Questions