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

57 Views Asked by At

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?

1

There are 1 best solutions below

3
Drew Reese On BEST ANSWER

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
}