Search code examples
typescriptreduxredux-thunkredux-toolkit

How to use createAsyncThunk with Typescript? How to set types for the `pending` and `rejected` payloads?


Right now I've got these actions that I use for an upload thunk's lifecycle.

type UPLOAD_START   = PayloadAction<void>
type UPLOAD_SUCCESS = PayloadAction<{ src: string, sizeKb: number }> 
type UPLOAD_FAILURE = PayloadAction<{ error: string }>

And I'd like to convert it to a createAsyncThunk call, assuming it will reduce the code. But will it?

From the example on https://redux-toolkit.js.org/api/createAsyncThunk it should be something like:

const uploadThumbnail = createAsyncThunk(
  'mySlice/uploadThumbnail',
  async (file: File, thunkAPI) => {
    const response = await uploadAPI.upload(file) as API_RESPONSE
    return response.data   // IS THIS THE payload FOR THE fulfilled ACTION ?
  }
)

This is how I would handle the life cycle actions?

const usersSlice = createSlice({
  name: 'mySlice',
  initialState: // SOME INITIAL STATE,
  reducers: {
    // standard reducer logic, with auto-generated action types per reducer
  },
  extraReducers: {
    // Add reducers for additional action types here, and handle loading state as needed
    [uploadThumbnail.pending]: (state,action) => {
       // HANDLE MY UPLOAD_START ACTION
    },
    [uploadThumbnail.fulfilled]: (state, action) => {
      // HANDLE MY UPLOAD_SUCCESS ACTION
    },
    [uploadThumbnail.rejected]: (state, action) => {
      // HANDLE MY UPLOAD_FAILURE ACTION
    },
  }
})

QUESTION

I'm assuming the return of the createAsyncThunk async handler is the payload for the fulfilled action, is that right?

But how can I set the payload types for the pending and the rejected actions? Should I add a try-catch block to the createAsyncThunk handler?

Is this the correlation I should be doing?

  • pending === "UPLOAD_START"
  • fulfilled === "UPLOAD_SUCCESS"
  • rejected === "UPLOAD_FAILURE"

Obs: From the pattern I'm imagining, it doesn't look I'll be writing any less code than what I'm already doing with three separate actions and handling them in my regular reducers (instead of doing it on the extraReducers prop). What is the point of using the createAsyncThunk in this case?


Solution

  • Most of your questions will be answered by looking at one of the TypeScript examples a little further down in the docs page you linked:

    export const updateUser = createAsyncThunk<
      User,
      { id: string } & Partial<User>,
      {
        rejectValue: ValidationErrors
      }
    >('users/update', async (userData, { rejectWithValue }) => {
      try {
        const { id, ...fields } = userData
        const response = await userAPI.updateById<UpdateUserResponse>(id, fields)
        return response.data.user
      } catch (err) {
        let error: AxiosError<ValidationErrors> = err // cast the error for access
        if (!error.response) {
          throw err
        }
        // We got validation errors, let's return those so we can reference in our component and set form errors
        return rejectWithValue(error.response.data)
      }
    })
    
    
    const usersSlice = createSlice({
      name: 'users',
      initialState,
      reducers: {},
      extraReducers: (builder) => {
        // The `builder` callback form is used here because it provides correctly typed reducers from the action creators
        builder.addCase(updateUser.fulfilled, (state, { payload }) => {
          state.entities[payload.id] = payload
        })
        builder.addCase(updateUser.rejected, (state, action) => {
          if (action.payload) {
            // Being that we passed in ValidationErrors to rejectType in `createAsyncThunk`, the payload will be available here.
            state.error = action.payload.errorMessage
          } else {
            state.error = action.error.message
          }
        })
      },
    })
    

    So, observations from there:

    • when using TypeScript, you should use the builder style notation for extraReducers and all your Types will be automatically inferred for you. You should not need to type anything down in extraReducers by hand - ever.
    • the returned value of your thunk will be the payload of the "fulfilled" action
    • if you return rejectWithResult(value), that will become the payload of the "rejected" action
    • if you just throw, that will become the error of the "rejected" action.

    Additional answers:

    • "pending" is your "UPLOAD_START". It does not have a payload and you cannot set it. All three of "pending"/"rejected"/"fulfilled" will have action.meta.arg though, which is the original value you passed into the thunk call.
    • in the end, this is probably a little less code than you would write from hand, and it will be very consistent throughout your application. Also, it catches some bugs that would go unseen otherwise. Do you know that
      const manualThunk = async (arg) => {
        dispatch(pendingAction())
        try {
          const result = await foo(arg)
          dispatch(successAction(result))
        } catch (e) {
          dispatch(errorAction(e))
        }
      }
    

    actually contains a bug? If successAction triggers a rerender (which it most likely does) and somewhere during that rerender, an error is thrown, that error will be caught in this try..catch block and another errorAction will be dispatched. So you will have a thunk with both the success and error case true at the same time. Awkward. This can be circumvented by storing the result in a scoped-up variable and dispatching outside of the try-catch-block, but who does that in reality? ;) It's these little things that createAsyncThunk takes care of for you that make it worth it in my eyes.