Skip to main content

🍰 createSlice and createAsyncThunk

The last lesson introduced the toolkit; this one gets your hands dirty with its two workhorses. createSlice is where your feature's state and synchronous logic live. createAsyncThunk is how that feature talks to the network. Master the way they fit together — a slice's extraReducers catching a thunk's lifecycle — and you can model almost any feature in modern Redux.

Week 6 · Thursday: Redux Toolkit · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write slice reducers in every form: simple, prepare-based, and reset reducers
  • Use the builder in extraReducers with addCase, addMatcher, and addDefaultCase
  • Trace a createAsyncThunk through its pending → fulfilled → rejected lifecycle
  • Use the thunkAPI object: getState, dispatch, rejectWithValue, and signal
  • Combine a slice and a thunk into one complete, working feature
  • Read a thunk's result in a component with .unwrap()

Estimated Time: 70 minutes

Practice: Build a posts feature — a slice plus a fetch thunk wired through extraReducers.

In This Lesson

Anatomy of a Slice

createSlice accepts four things and returns two. In goes a name, an initialState, a reducers object, and an optional extraReducers callback. Out come the generated actions and a single reducer.

🍰 The Layer Cake Analogy

A slice is a layer cake for one feature. The name is the label on the box. The initialState is the base layer. The reducers are the layers you own and stack yourself. extraReducers are decorations borrowed from other cakes — actions baked elsewhere that your slice reacts to. And the actions are the frosting that ties it all together so components can order a slice.

graph TD A["createSlice(config)"] --> B["name"] A --> C["initialState"] A --> D["reducers"] A --> E["extraReducers"] D --> F["Simple reducers"] D --> G["prepare reducers"] E --> H["builder.addCase"] E --> I["builder.addMatcher"] A --> J["slice.actions"] A --> K["slice.reducer"]
import { createSlice } from '@reduxjs/toolkit';

const featureSlice = createSlice({
  name: 'feature',              // seeds action types: 'feature/actionName'
  initialState: { /* ... */ },
  reducers: {                   // actions this slice OWNS (creators generated)
    actionName: (state, action) => {
      // "mutate" the Immer draft directly
    }
  },
  extraReducers: (builder) => { // REACT to actions defined elsewhere
    builder.addCase(someExternalAction, (state, action) => { /* ... */ });
  }
});

export const { actionName } = featureSlice.actions;  // action creators
export default featureSlice.reducer;                  // the reducer

Reducer Patterns

Inside reducers, each key can take one of three shapes. Knowing all three lets you keep components dumb and logic centralized.

1. Simple reducer

setFilter: (state, action) => {
  state.filter = action.payload;   // whatever you dispatch is action.payload
}

2. Reducer with a prepare callback

When the stored payload needs generated fields (id, timestamps) or validation, split the logic: prepare builds the action, reducer applies it.

import { nanoid } from '@reduxjs/toolkit';

addTodo: {
  reducer: (state, action) => {
    state.items.push(action.payload);
  },
  prepare: (text) => ({
    payload: {
      id: nanoid(),                 // RTK ships its own id generator
      text,
      completed: false,
      createdAt: new Date().toISOString()
    }
  })
}

// Component only supplies the text:
dispatch(addTodo('Write the report'));

💡 prepare for validation too

updateTodo: {
  reducer: (state, action) => {
    const { id, updates } = action.payload;
    const todo = state.items.find(t => t.id === id);
    if (todo) Object.assign(todo, updates);
  },
  prepare: (id, updates) => {
    if (!id) throw new Error('id is required');
    return { payload: { id, updates } };
  }
}

3. Reset reducer (return a new value)

To wipe state back to a known shape, return a fresh object instead of mutating. Remember the Immer rule: return-only, don't also touch the draft.

resetTodos: () => ({ items: [], filter: 'all', loading: false })

extraReducers & the Builder

extraReducers receives a builder object with a small, chainable API. This is the modern, TypeScript-friendly way to respond to actions your slice doesn't own — most importantly, async thunks.

Builder methodMatchesTypical use
addCase(action, fn)One exact action typeA thunk's .pending / .fulfilled / .rejected
addMatcher(predicate, fn)Any action passing a testEvery action ending in /rejected
addDefaultCase(fn)Anything not matched aboveA fallback (rarely needed)
const postsSlice = createSlice({
  name: 'posts',
  initialState: { items: [], status: 'idle', error: null },
  reducers: {
    postAdded: (state, action) => { state.items.push(action.payload); }
  },
  extraReducers: (builder) => {
    builder
      // exact matches, in order
      .addCase(fetchPosts.pending,   (state) => { state.status = 'loading'; })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected,  (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      })
      // catch-all for ANY rejected thunk across the app
      .addMatcher(
        (action) => action.type.endsWith('/rejected'),
        (state, action) => { state.error = action.error?.message ?? 'Unknown error'; }
      );
  }
});

⚠️ Order matters, and always call addCase first

The builder applies addCase handlers, then addMatcher handlers (in the order added), then addDefaultCase. All matching handlers run, so a broad matcher won't replace a specific case — it runs after it. The old object-notation form of extraReducers is deprecated; always use the builder callback.

The Async Thunk Lifecycle

createAsyncThunk takes a type prefix and an async "payload creator." Dispatching the thunk immediately fires a pending action; when your async function resolves it fires fulfilled (with the return value as payload); if it throws it fires rejected.

sequenceDiagram participant C as Component participant T as Thunk participant R as Reducer participant API as Server C->>T: dispatch(fetchPosts()) T->>R: fetchPosts.pending R->>C: status = 'loading' T->>API: GET /posts API-->>T: response alt resolved T->>R: fetchPosts.fulfilled (payload) R->>C: status = 'succeeded' else threw T->>R: fetchPosts.rejected (error) R->>C: status = 'failed' end
import { createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUserById = createAsyncThunk(
  'users/fetchById',            // → 'users/fetchById/pending' | '/fulfilled' | '/rejected'
  async (userId) => {
    const res = await fetch(`/api/users/${userId}`);
    if (!res.ok) throw new Error('Request failed');
    return res.json();          // this becomes action.payload on fulfilled
  }
);

// The thunk object carries the three generated action creators:
fetchUserById.pending.type;    // 'users/fetchById/pending'
fetchUserById.fulfilled.type;  // 'users/fetchById/fulfilled'
fetchUserById.rejected.type;   // 'users/fetchById/rejected'

In a component

const dispatch = useDispatch();
const { entities, loading, error } = useSelector(state => state.users);

useEffect(() => { dispatch(fetchUserById(userId)); }, [dispatch, userId]);

if (loading) return <Spinner />;
if (error)   return <p>Error: {error}</p>;

Working with thunkAPI

The payload creator's second argument is thunkAPI — a toolbox for talking to the rest of the store from inside async logic. The four you'll use constantly:

  • getState() — read current state (e.g. grab an auth token)
  • dispatch() — fire other actions or thunks
  • rejectWithValue(value) — reject with a custom payload instead of a thrown Error
  • signal — an AbortController signal for cancellation

Custom error payloads with rejectWithValue

A thrown Error only exposes its message on action.error. When your API returns a structured error (validation fields, a code), use rejectWithValue so the whole object lands on action.payload.

export const loginUser = createAsyncThunk(
  'auth/login',
  async (credentials, { getState, dispatch, rejectWithValue }) => {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials)
    });

    if (!res.ok) {
      const problem = await res.json();
      return rejectWithValue(problem);   // → action.payload in the reducer
    }

    const data = await res.json();
    if (getState().settings.rememberMe) {
      localStorage.setItem('token', data.token);
    }
    dispatch(fetchUserProfile());        // chain another thunk
    return data;
  }
);

// In the slice:
.addCase(loginUser.rejected, (state, action) => {
  // action.payload holds the server's structured error (from rejectWithValue)
  // action.error.message holds a thrown error's message (the fallback)
  state.error = action.payload?.message ?? action.error.message;
})

Skipping unnecessary work with condition

A third argument to createAsyncThunk lets you bail out before the request even starts — great for avoiding duplicate fetches.

export const fetchNotifications = createAsyncThunk(
  'notifications/fetch',
  async () => (await fetch('/api/notifications')).json(),
  {
    condition: (_arg, { getState }) => {
      const { status, lastFetched } = getState().notifications;
      if (status === 'loading') return false;                 // already in flight
      if (lastFetched && Date.now() - lastFetched < 60_000) return false; // still fresh
      return true;
    }
  }
);

Cancellation with signal

export const searchProducts = createAsyncThunk(
  'products/search',
  async (query, { signal }) => {
    const res = await fetch(`/api/products?q=${query}`, { signal });
    return res.json();
  }
);

// Dispatching returns a promise with an .abort() method:
const promise = dispatch(searchProducts('laptop'));
promise.abort();   // cancels the in-flight request → rejected with 'Aborted'

A Complete Feature

Here is a realistic posts feature that ties everything together: async thunks with error handling, sync reducers, extraReducers wiring, and co-located selectors.

// features/posts/postsSlice.js
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { client } from '../../api/client';

// --- Thunks ---
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
  const res = await client.get('/api/posts');
  return res.data;
});

export const addNewPost = createAsyncThunk(
  'posts/addNewPost',
  async (initialPost, { getState, rejectWithValue }) => {
    try {
      const { auth } = getState();
      const post = { ...initialPost, author: auth.user.id, date: new Date().toISOString() };
      const res = await client.post('/api/posts', post);
      return res.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

// --- Slice ---
const postsSlice = createSlice({
  name: 'posts',
  initialState: { items: [], status: 'idle', error: null },
  reducers: {
    reactionAdded: (state, action) => {
      const { postId, reaction } = action.payload;
      const post = state.items.find(p => p.id === postId);
      if (post) post.reactions[reaction]++;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending,   (state) => { state.status = 'loading'; })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected,  (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      })
      .addCase(addNewPost.fulfilled, (state, action) => {
        state.items.push(action.payload);
      });
  }
});

export const { reactionAdded } = postsSlice.actions;
export default postsSlice.reducer;

// --- Selectors (co-located) ---
export const selectAllPosts = (state) => state.posts.items;
export const selectPostById = (state, id) => state.posts.items.find(p => p.id === id);

// Memoized derived selector
export const selectPostsByUser = createSelector(
  [selectAllPosts, (_state, userId) => userId],
  (posts, userId) => posts.filter(p => p.author === userId)
);

💡 Why createSelector?

selectPostsByUser creates a new filtered array each call. Wrapping it in createSelector memoizes the result — it only recomputes when posts or userId actually change, preventing needless component re-renders.

Reading Results with unwrap

Dispatching a thunk returns a special promise that always resolves — even on failure — so a bare await won't throw. Call .unwrap() to get a normal promise that resolves with the payload or rejects on failure, letting you use plain try/catch in the component.

const AddPostForm = () => {
  const dispatch = useDispatch();
  const [status, setStatus] = useState('idle');

  const onSave = async (fields) => {
    try {
      setStatus('pending');
      // .unwrap() throws if the thunk rejected → jumps to catch
      const savedPost = await dispatch(addNewPost(fields)).unwrap();
      console.log('Saved post id:', savedPost.id);
    } catch (err) {
      console.error('Failed to save the post:', err);
    } finally {
      setStatus('idle');
    }
  };
  // ...
};

✅ The rule of thumb

Handle loading/error state declaratively via the slice (from selectors). Use .unwrap() only when the component needs to react to this specific dispatch's outcome — like resetting a form or navigating on success.

Practice & Quiz

🏋️ Exercise 1: Wire a fetch thunk into a slice

Goal: Given a fetchTodos thunk, complete a todos slice that tracks status and stores the result.

export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
  const res = await fetch('/api/todos');
  return res.json();
});

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [], status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    // TODO: handle pending, fulfilled, rejected
  }
});
💡 Hint

Use builder.addCase(fetchTodos.pending, ...) etc. On fulfilled, set state.items = action.payload. On rejected, read action.error.message.

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

🏋️ Exercise 2: Return a structured error

Goal: Rewrite this thunk so a 400 response surfaces the server's message on action.payload instead of a generic thrown error.

export const saveTodo = createAsyncThunk('todos/save', async (todo) => {
  const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) });
  return res.json();   // no error handling yet
});
✅ Solution
export const saveTodo = createAsyncThunk(
  'todos/save',
  async (todo, { rejectWithValue }) => {
    const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) });
    const data = await res.json();
    if (!res.ok) return rejectWithValue(data);   // data.message lands on action.payload
    return data;
  }
);

🎯 Quick Quiz

Question 1: A createAsyncThunk's async function returns a value. Which action carries it, and where?

Question 2: You need a structured error object in the reducer, not just a message. What do you use?

Question 3: Why call .unwrap() on a dispatched thunk in a component?

Best Practices & Pitfalls

✅ Do

  • Handle all three thunk states — pending, fulfilled, rejected — every time
  • Use the builder callback form of extraReducers (the object form is deprecated)
  • Use prepare to keep id/timestamp generation out of components
  • Use rejectWithValue for structured API errors; read them from action.payload
  • Co-locate selectors with the slice and memoize derived ones with createSelector

❌ Don't

  • Make API calls inside a reducer — reducers must stay pure; that's the thunk's job
  • Both mutate the draft and return it in one reducer (Immer throws)
  • await dispatch(thunk()) expecting it to throw — it won't; use .unwrap()
  • Reach into raw state shape from components — go through selectors

⚠️ A common race-condition guard

If a screen can trigger the same fetch twice, track the request id so a stale response can't clobber a newer one:

.addCase(fetchPosts.pending, (state, action) => {
  if (state.status === 'idle') {
    state.status = 'loading';
    state.currentRequestId = action.meta.requestId;
  }
})
.addCase(fetchPosts.fulfilled, (state, action) => {
  if (state.currentRequestId === action.meta.requestId) {
    state.status = 'succeeded';
    state.items = action.payload;
    state.currentRequestId = undefined;
  }
})

Summary

🎉 Key Takeaways

  • createSlice takes name + initialState + reducers (+ extraReducers) and returns actions + a reducer
  • Reducers come in three shapes: simple, prepare-based, and reset (return-only)
  • extraReducers uses a builder: addCase for exact types, addMatcher for predicates
  • createAsyncThunk generates pending / fulfilled / rejected from one async function
  • thunkAPI gives you getState, dispatch, rejectWithValue, and signal
  • Use .unwrap() when a component must react to a specific dispatch's result

📚 Additional Resources

🚀 What's Next?

Writing thunks for every fetch, plus tracking loading/error state by hand, still adds up. The next lesson introduces RTK Query — a data-fetching layer built on these same primitives that generates the thunks, the cache, and the React hooks for you.

🎉 The workhorses, mastered!

You can now model any feature: state, sync logic, and async data flow, all in one tidy slice.