Search code examples
reduxredux-toolkitredux-thunk

How error handling with redux thunk + fetch API should be implemented?


Somehow I didn't find working example for the most basic case. I took example e.g. from here.

State:

interface AppUserState {
  userId: string | undefined;
  user: User;
  status: 'idle' | 'pending' | 'succeeded' | 'failed';
  error: Error | null;
}

I have a thunk:

export const fetchUser = createAsyncThunk(
  'app-user/fetch-user',
  async (id: string) => {
    const response = await fetch(`/api/v1/user/id/${id}`);
    return await response.json();
  }
);

And a slice:

const usersSlice = createSlice({
  name: 'app-user',
  initialState,
  reducers: {
    setUserId(state, action: PayloadAction<string | undefined>) {
      state.userId = action.payload;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending.type, (state) => {
        state.status = 'pending';
      })
      .addCase(fetchUser.fulfilled.type, (state, action: PayloadAction<any>) => {
        if (state.status === 'pending') {
          state.status = 'succeeded';
          state.user = {
            id: action.payload.id,
            name: action.payload.name,
            type: action.payload.Type
          };
        }
      })
      .addCase(fetchUser.rejected.type, (state, action: PayloadAction<any>) => {
        if (state.status === 'pending') {
          state.status = 'failed';
          state.error = action.payload;
        }
      })
  }
});

This doesn't work. When there's no user and fetch returns 404 rejected case is not handled.

FIX:

If I change the fetch to code below the rejected case is handled but the payload doesn't contain the error I pass to reject.

export const fetchUser = createAsyncThunk(
  'app-user/fetch-user',
  async (id: string) => {
    const response = await fetch(`/api/v1/user/id/${id}`);
    if (!response.ok) {
      return Promise.reject(new Error(response.statusText));
    }
    return await response.json();
  }
);

Instead the error is directly in action. This can be fixed by changing the rejected case:

      .addCase(fetchUser.rejected.type, (state, action) => {
        if (state.status === 'pending') {
          state.status = 'failed';
          if ('error' in action) {
            state.error = action.error as Error;
          }
        }
      })

But this seems not to be the standard way to do this. There's lots of examples that are not for fetch API. So how this should be done?


Solution

  • fetch does not reject on status 404. See Checking that the fetch was successful for details.

    It would seem you are about halfway there to processing the fetched data. I would suggest just surrounding all the asynchronous code/logic in a try/catch, assume the "happy path", and if there are any errors/rejections along the way then return the error value with rejectWithValue instead of throwing another error or returning a Promise.reject. This places the "error" (whatever it is) on the *.rejected action's payload.

    Example:

    export const fetchUser = createAsyncThunk(
      'app-user/fetch-user',
      async (id: string, { thunkApi }) => {
        try {
          const response = await fetch(`/api/v1/user/id/${id}`);
          if (!response.ok) {
            return thunkApi.rejectWithValue(new Error(response.statusText));
          }
          return response.json();
        } catch(error) {
          return thunkApi.rejectWithValue(error);
        }
      }
    );
    
    const usersSlice = createSlice({
      name: 'app-user',
      initialState,
      reducers: {
        setUserId(state, action: PayloadAction<string | undefined>) {
          state.userId = action.payload;
        }
      },
      extraReducers: (builder) => {
        builder
          .addCase(fetchUser.pending, (state) => {
            state.status = 'pending';
          })
          .addCase(fetchUser.fulfilled, (state, action: PayloadAction<any>) => {
            if (state.status === 'pending') {
              state.status = 'succeeded';
              state.user = {
                id: action.payload.id,
                name: action.payload.name,
                type: action.payload.Type
              };
              state.error = null; // <-- clear any errors on success
            }
          })
          .addCase(fetchUser.rejected, (state, action: PayloadAction<any>) => {
            if (state.status === 'pending') {
              state.status = 'failed';
              state.error = action.payload; // *
            }
          })
      }
    });
    

    Note: that you may need to adjust the AppUserState interface to match anything you are rejecting in the thunk and setting into state.*

    interface AppUserState {
      userId?: string;
      user: User;
      status: 'idle' | 'pending' | 'succeeded' | 'failed';
      error: Error | null; // <-- tweak this to match the code used
    }