Code To Learn logo

Code To Learn

M6: State ManagementRedux Toolkit Path

L6: Extra Reducers

Handle async thunk states with extraReducers

Now let's handle the async thunk's three states: pending, fulfilled, and rejected!

Reducers vs Extra Reducers

Redux Toolkit slices have two ways to handle actions:

reducers - Actions generated BY this slice:

const listingsSlice = createSlice({
  name: 'listings',
  initialState,
  reducers: {
    toggleFavorite: (state, action) => {
      // Handles: listings/toggleFavorite
    },
    clearFilters: (state) => {
      // Handles: listings/clearFilters
    }
  }
});
  • Actions are auto-generated
  • Action creators exported: toggleFavorite(), clearFilters()
  • Used for synchronous state updates

extraReducers - Actions generated OUTSIDE this slice:

const listingsSlice = createSlice({
  name: 'listings',
  initialState,
  reducers: { /* ... */ },
  extraReducers: (builder) => {
    builder
      .addCase(fetchListings.pending, (state) => {
        // Handles: listings/fetchListings/pending
      })
      .addCase(fetchListings.fulfilled, (state, action) => {
        // Handles: listings/fetchListings/fulfilled
      })
      .addCase(fetchListings.rejected, (state, action) => {
        // Handles: listings/fetchListings/rejected
      });
  }
});
  • Handles external actions (async thunks, other slices)
  • Actions NOT auto-generated (they come from thunks)
  • Used for asynchronous operations
FeaturereducersextraReducers
PurposeSync actions from this sliceActions from outside (thunks, other slices)
ActionsAuto-generatedExternal (must reference)
ExportAction creators exportedNo exports (handles existing actions)
Use caseToggle favorite, clear formAPI calls, complex async logic
SyntaxObject notationBuilder callback

Rule of thumb:

  • Simple state changes → reducers
  • Async operations → extraReducers

What We're Building

We'll add extraReducers to handle all three fetch states:

  1. Pending - Set loading state
  2. Fulfilled - Store fetched listings
  3. Rejected - Store error message

Step 1: Add extraReducers

Open src/state/slices/listingsSlice.js and add extraReducers after the reducers:

src/state/slices/listingsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';

const initialState = {
  items: [],
  favorites: [],
  status: 'idle',
  error: null,
};

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}) => {
    const response = await api.get('/listings', {
      params: filters,
    });
    return response.data;
  }
);

const listingsSlice = createSlice({
  name: 'listings',
  initialState,
  reducers: {
    toggleFavorite: (state, action) => {
      const id = action.payload;
      
      if (state.favorites.includes(id)) {
        state.favorites = state.favorites.filter(favoriteId => favoriteId !== id);
      } else {
        state.favorites.push(id);
      }
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchListings.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchListings.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchListings.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export const { toggleFavorite } = listingsSlice.actions;
export default listingsSlice.reducer;

What's happening here?

Builder Pattern

extraReducers: (builder) => {
  builder
    .addCase(/* ... */)
    .addCase(/* ... */)
    .addCase(/* ... */);
}

The builder object provides methods to handle different action types:

  • addCase() - Handle specific action type
  • addMatcher() - Handle multiple actions matching a pattern
  • addDefaultCase() - Handle all other actions

We use method chaining to handle multiple cases cleanly.

Pending Case

.addCase(fetchListings.pending, (state) => {
  state.status = 'loading';
  state.error = null;
})

When: Dispatched when fetchListings() starts

What it does:

  • Sets status to 'loading'
  • Clears previous errors

Use in UI:

const status = useSelector((state) => state.listings.status);
if (status === 'loading') return <Spinner />;

Fulfilled Case

.addCase(fetchListings.fulfilled, (state, action) => {
  state.status = 'succeeded';
  state.items = action.payload;
})

When: Dispatched when API call succeeds

What it does:

  • Sets status to 'succeeded'
  • Stores fetched data in state.items

action.payload contains the return value from the thunk:

// In thunk
return response.data;  // ← This

// In reducer
state.items = action.payload;  // ← Becomes this

Rejected Case

.addCase(fetchListings.rejected, (state, action) => {
  state.status = 'failed';
  state.error = action.error.message;
})

When: Dispatched when API call fails

What it does:

  • Sets status to 'failed'
  • Stores error message

Use in UI:

const error = useSelector((state) => state.listings.error);
if (error) return <ErrorMessage message={error} />;

Complete State Flow

Let's trace the complete state changes when fetching listings:

Initial State

{
  items: [],
  favorites: [],
  status: 'idle',
  error: null
}

After Dispatch

dispatch(fetchListings());

Pending action fires:

{
  items: [],
  favorites: [],
  status: 'loading',  // ← Changed
  error: null
}

After Success

Fulfilled action fires:

{
  items: [/* API data */],  // ← Populated
  favorites: [],
  status: 'succeeded',      // ← Changed
  error: null
}

After Error (Alternative)

If the API call failed, rejected action fires:

{
  items: [],
  favorites: [],
  status: 'failed',                      // ← Changed
  error: 'Network request failed'        // ← Set
}

Using Status in Components

Now components can check the status:

src/pages/HomePage.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchListings } from '@/state/slices/listingsSlice';

function HomePage() {
  const dispatch = useDispatch();
  const { items, status, error } = useSelector((state) => state.listings);
  
  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchListings());
    }
  }, [status, dispatch]);
  
  if (status === 'loading') {
    return <div>Loading...</div>;
  }
  
  if (status === 'failed') {
    return <div>Error: {error}</div>;
  }
  
  return (
    <div>
      {items.map(listing => (
        <div key={listing.id}>{listing.title}</div>
      ))}
    </div>
  );
}

Much cleaner than local state! All loading/error logic is in Redux.

extraReducers Syntax Options

There are two ways to write extraReducers:

extraReducers: (builder) => {
  builder
    .addCase(fetchListings.pending, (state) => {
      state.status = 'loading';
    })
    .addCase(fetchListings.fulfilled, (state, action) => {
      state.status = 'succeeded';
      state.items = action.payload;
    })
    .addCase(fetchListings.rejected, (state, action) => {
      state.status = 'failed';
      state.error = action.error.message;
    });
}

Pros:

  • ✅ Better TypeScript support
  • ✅ Catches typos and errors
  • ✅ Recommended by Redux Toolkit
  • ✅ Chainable syntax
extraReducers: {
  [fetchListings.pending]: (state) => {
    state.status = 'loading';
  },
  [fetchListings.fulfilled]: (state, action) => {
    state.status = 'succeeded';
    state.items = action.payload;
  },
  [fetchListings.rejected]: (state, action) => {
    state.status = 'failed';
    state.error = action.error.message;
  }
}

Cons:

  • ❌ Deprecated in newer versions
  • ❌ Worse TypeScript support
  • ❌ Harder to catch errors

Use the builder callback pattern!

Advanced Patterns

Pattern 1: Handle Multiple Thunks

extraReducers: (builder) => {
  builder
    // fetchListings thunk
    .addCase(fetchListings.pending, (state) => {
      state.status = 'loading';
    })
    .addCase(fetchListings.fulfilled, (state, action) => {
      state.status = 'succeeded';
      state.items = action.payload;
    })
    // fetchListingDetails thunk
    .addCase(fetchListingDetails.pending, (state) => {
      state.detailsStatus = 'loading';
    })
    .addCase(fetchListingDetails.fulfilled, (state, action) => {
      state.detailsStatus = 'succeeded';
      state.currentListing = action.payload;
    });
}

Pattern 2: Use Matcher

Handle all pending actions at once:

extraReducers: (builder) => {
  builder
    .addMatcher(
      (action) => action.type.endsWith('/pending'),
      (state) => {
        state.status = 'loading';
      }
    )
    .addMatcher(
      (action) => action.type.endsWith('/fulfilled'),
      (state) => {
        state.status = 'succeeded';
      }
    )
    .addMatcher(
      (action) => action.type.endsWith('/rejected'),
      (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      }
    );
}

Use when: You have many thunks with similar patterns.

Pattern 3: Default Case

extraReducers: (builder) => {
  builder
    .addCase(fetchListings.fulfilled, (state, action) => {
      state.items = action.payload;
    })
    .addDefaultCase((state, action) => {
      // Handle any other action
      console.log('Unhandled action:', action.type);
    });
}

Testing in Redux DevTools

Let's verify everything works:

Open Redux DevTools

  1. Open your app in browser
  2. Open DevTools (F12)
  3. Click Redux tab

Dispatch Manually

In the Dispatch section, manually dispatch:

{
  "type": "listings/fetchListings/pending"
}

Check state - you should see:

{
  "listings": {
    "status": "loading",
    "error": null
  }
}

Dispatch Fulfilled

Now dispatch:

{
  "type": "listings/fetchListings/fulfilled",
  "payload": [
    { "id": 1, "title": "Test Listing" }
  ]
}

Check state:

{
  "listings": {
    "items": [
      { "id": 1, "title": "Test Listing" }
    ],
    "status": "succeeded"
  }
}

Perfect! 🎉

Complete Code

Here's the complete slice with extraReducers:

src/state/slices/listingsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';

const initialState = {
  items: [],
  favorites: [],
  status: 'idle',
  error: null,
};

export const fetchListings = createAsyncThunk(
  'listings/fetchListings',
  async (filters = {}) => {
    const response = await api.get('/listings', {
      params: filters,
    });
    return response.data;
  }
);

const listingsSlice = createSlice({
  name: 'listings',
  initialState,
  reducers: {
    toggleFavorite: (state, action) => {
      const id = action.payload;
      
      if (state.favorites.includes(id)) {
        state.favorites = state.favorites.filter(favoriteId => favoriteId !== id);
      } else {
        state.favorites.push(id);
      }
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchListings.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchListings.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchListings.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export const { toggleFavorite } = listingsSlice.actions;
export default listingsSlice.reducer;

What's Next?

Excellent! The slice now handles async fetch states. In the next lesson, we'll:

  1. Refactor HomePage - Use Redux instead of local state
  2. Dispatch fetchListings - Load data on mount
  3. Remove duplicate code - Eliminate local fetch logic

✅ Lesson Complete! Your slice now handles all three async states: pending, fulfilled, and rejected!

Key Takeaways

  • ✅ Use reducers for sync actions generated by the slice
  • ✅ Use extraReducers for external actions (thunks, other slices)
  • ✅ Builder callback pattern is recommended over map object
  • ✅ Handle three states: pending, fulfilled, rejected
  • action.payload contains the thunk's return value
  • action.error contains error information
  • ✅ Status field tracks: idle → loading → succeeded/failed