char m
char m

Reputation: 8336

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?

Upvotes: 1

Views: 248

Answers (1)

Drew Reese
Drew Reese

Reputation: 202721

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
}

Upvotes: 1

Related Questions