| |

React 67 ⚛️ Redux Slices, Actions, and Reducers

⏱️ Estimated read: 13 min | |

A Redux store is a single object, and the only way to change it is to dispatch an action. The action is a plain object with a type and an optional payload. The reducer is a pure function that takes the current state and the action, and returns the new state. The classic Redux required the developer to write the action types, the action creators, and the switch statements by hand. The Redux Toolkit collapses the three into the createSlice, which generates the action creators and the reducer from a single object. This chapter covers the slice, the action, and the reducer, and the patterns that make them predictable.

Key point: A reducer is a pure function. It must not mutate its arguments, it must not perform side effects, and given the same state and action, it must return the same result. The Immer library inside the Redux Toolkit lets the developer write the mutation code, and the library produces the new state without violating the purity.


Why slices, actions, and reducers exist

The single-source-of-truth problem. The application state lives in one place. The component reads it with the selector, and the component writes it with the dispatch. The store is the one place, and the components are the views.

The predictability problem. The state change is the result of the action. The action is the record of what happened, and the reducer is the rule that determines the new state. The DevTools show the action log, and the time-travel debugging replays the actions.

The pure-function problem. The reducer is the pure function. The test is the input and the expected output, and the reducer does not depend on the outside world.

The boilerplate problem. The classic Redux required the action type constants, the action creator functions, and the switch statements. The createSlice generates the three from the one object.

The immutability problem. The reducer must return the new object. The spread operators produced the verbose and the error-prone code. The Immer produces the new state from the “mutation” code.

The composition problem. The store combines the slices. Each slice owns the part of the state, and the combineReducers (via the configureStore‘s reducer object) combines them.

The type problem. The TypeScript types are inferred from the slice. The PayloadAction<T> is the action type, and the RootState is the store type.


a. The slice

The createSlice takes the name, the initial state, and the reducers. It returns the object with the .actions and the .reducer.

The basic slice

// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  items: [],
  filter: 'all',
  status: 'idle',
};

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action) => {
      state.items.push({
        id: Date.now(),
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
    removeTodo: (state, action) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
    setFilter: (state, action) => {
      state.filter = action.payload;
    },
  },
});

export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;

The name is the prefix for the action types, the initialState is the state, and the reducers is the map of the handlers.

The generated action types

The name and the reducer key produce the action type. The addTodo reducer produces the 'todos/addTodo' type.

todos/addTodo
todos/toggleTodo
todos/removeTodo
todos/setFilter

The type is the name and the / and the reducer key.

The generated action creators

The .actions holds the action creators. The addTodo('text') returns the { type: 'todos/addTodo', payload: 'text' }.

const action = addTodo('Learn Redux');
// { type: 'todos/addTodo', payload: 'Learn Redux' }

The Immer and the state update

The state.items.push(...) looks like the mutation. The Immer wraps the state in the proxy, and the mutation produces the new state.

addTodo: (state, action) => {
  state.items.push({ ... });  // The Immer produces the new state.
},

The Immer tracks the changes and produces the immutable result. The developer writes the mutation code, and the library guarantees the immutability.

The returning versus the mutating

The reducer can return the new state explicitly, or mutate the draft. The two forms are the equivalent, and the mutation is the more readable.

// The returning form
addTodo: (state, action) => {
  return { ...state, items: [...state.items, action.payload] };
},

// The mutating form (the Immer)
addTodo: (state, action) => {
  state.items.push(action.payload);
},

The mutating form is the recommended in the Redux Toolkit.

The slice table

ElementPurpose
nameThe action type prefix
initialStateThe state
reducersThe handlers
.actionsThe creators
.reducerThe reducer
.nameThe slice name

b. The action

The action is the plain object with the type and the optional payload. The PayloadAction<T> is the TypeScript type for the action with the payload.

The action shape

{
  type: 'todos/addTodo',
  payload: 'Learn Redux',
}

The type is the string, and the payload is the value.

The prepare callback

The prepare callback customizes the payload. The reducer receives the prepared payload.

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: {
      reducer: (state, action) => {
        state.items.push(action.payload);
      },
      prepare: (text) => {
        return {
          payload: {
            id: Date.now(),
            text,
            completed: false,
          },
        };
      },
    },
  },
});

The prepare runs when the action creator is called, and the reducer runs when the action is dispatched.

The nanoid for the IDs

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

prepare: (text) => {
  return {
    payload: {
      id: nanoid(),
      text,
      completed: false,
    },
  };
},

The nanoid generates the unique ID. The Redux Toolkit includes it.

The PayloadAction type

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({ id: nanoid(), text: action.payload, completed: false });
    },
    removeTodo: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
  },
});

The PayloadAction<string> types the payload as the string.

The action table

ElementPurpose
typeThe action identifier
payloadThe data
metaThe metadata
errorThe error flag
prepareThe payload factory
PayloadAction<T>The typed action

c. The extraReducers

The extraReducers handles the actions the slice does not create. The async thunks and the other slices’ actions are the cases.

The async thunk

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

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

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [], status: 'idle', error: null },
  reducers: { ... },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
      })
      .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;
      });
  },
});

The createAsyncThunk creates the three actions: the pending, the fulfilled, and the rejected. The extraReducers handles the three.

The builder methods

MethodPurpose
addCaseThe single action
addMatcherThe predicate match
addDefaultCaseThe fallback

The addMatcher

extraReducers: (builder) => {
  builder.addMatcher(
    (action) => action.type.endsWith('/rejected'),
    (state, action) => {
      state.error = action.error.message;
    }
  );
},

The addMatcher matches the multiple actions with the predicate.

The extraReducers table

ElementPurpose
createAsyncThunkThe async action
.pendingThe loading
.fulfilledThe success
.rejectedThe failure
addCaseThe single
addMatcherThe predicate

Complete Example Session

// ============================================
// PART 1: THE BASIC SLICE
// ============================================

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

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    addTodo: (state, action) => {
      state.items.push({ id: Date.now(), text: action.payload });
    },
    removeTodo: (state, action) => {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
  },
});

export const { addTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;

// ============================================
// PART 2: THE ACTION SHAPE
// ============================================

const action = addTodo('Learn Redux');
// { type: 'todos/addTodo', payload: 'Learn Redux' }

// ============================================
// PART 3: THE PREPARE CALLBACK
// ============================================

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

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    addTodo: {
      reducer: (state, action) => {
        state.items.push(action.payload);
      },
      prepare: (text) => ({
        payload: { id: nanoid(), text, completed: false },
      }),
    },
  },
});

// ============================================
// PART 4: THE COMPONENT USAGE
// ============================================

import { useAppDispatch, useAppSelector } from '../app/hooks';
import { addTodo, removeTodo } from './todosSlice';

function TodoList() {
  const todos = useAppSelector((state) => state.todos.items);
  const dispatch = useAppDispatch();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.text}
          <button onClick={() => dispatch(removeTodo(todo.id))}>Remove</button>
        </li>
      ))}
      <button onClick={() => dispatch(addTodo('New'))}>Add</button>
    </ul>
  );
}

// ============================================
// PART 5: THE TYPESCRIPT SLICE
// ============================================

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodosState {
  items: Todo[];
}

const initialState: TodosState = { items: [] };

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({ id: nanoid(), text: action.payload, completed: false });
    },
  },
});

// ============================================
// PART 6: THE ASYNC THUNK
// ============================================

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

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

// ============================================
// PART 7: THE EXTRAREDUCERS
// ============================================

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [], status: 'idle', error: null },
  reducers: { ... },
  extraReducers: (builder) => {
    builder
      .addCase(fetchTodos.pending, (state) => {
        state.status = 'loading';
      })
      .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;
      });
  },
});

// ============================================
// PART 8: THE ADDMATCHER
// ============================================

extraReducers: (builder) => {
  builder.addMatcher(
    (action) => action.type.endsWith('/rejected'),
    (state, action) => {
      state.error = action.error.message;
    }
  );
},

// ============================================
// PART 9: THE DISPATCH
// ============================================

const dispatch = useAppDispatch();
dispatch(fetchTodos());
dispatch(addTodo('Learn Redux'));
dispatch(removeTodo(todoId));

// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================

// 1. Use the createSlice for the reducer and the actions.
// 2. The name is the action type prefix.
// 3. The reducers are the mutation code with the Immer.
// 4. The .actions holds the creators.
// 5. The .reducer is the slice reducer.
// 6. Use the prepare callback for the custom payload.
// 7. Use the createAsyncThunk for the async.
// 8. Use the extraReducers for the external actions.
// 9. Use the PayloadAction<T> for the TypeScript.
// 10. Use the nanoid for the unique IDs.

The ten parts showed the basic slice, the action shape, the prepare callback, the component usage, the TypeScript slice, the async thunk, the extraReducers, the addMatcher, the dispatch, and the complete pattern.


Quick Reference

The createSlice

ElementPurpose
nameThe action type prefix
initialStateThe state
reducersThe handlers
extraReducersThe external handlers
.actionsThe creators
.reducerThe reducer

The Action

ElementPurpose
typeThe identifier
payloadThe data
metaThe metadata
errorThe error flag

The prepare

ElementPurpose
reducerThe handler
prepareThe payload factory

The extraReducers

ElementPurpose
addCaseThe single
addMatcherThe predicate
addDefaultCaseThe fallback

The Async Thunk

ElementPurpose
.pendingThe loading
.fulfilledThe success
.rejectedThe failure

Best Practices

✅ Do This:

// Use the createSlice.
const todosSlice = createSlice({ name: 'todos', ... });  // ✅

// Use the Immer mutation.
addTodo: (state, action) => {
  state.items.push(action.payload);
},  // ✅

// Use the prepare for the payload.
prepare: (text) => ({ payload: { id: nanoid(), text } }),  // ✅

// Use the PayloadAction type.
addTodo: (state, action: PayloadAction<string>) => { ... },  // ✅

// Use the extraReducers for the thunk.
builder.addCase(fetchTodos.fulfilled, ...),  // ✅

// Use the nanoid for the IDs.
id: nanoid(),  // ✅

// Export the actions and the reducer.
export const { addTodo } = todosSlice.actions;
export default todosSlice.reducer;  // ✅

❌ Don’t Do This:

// Don't mutate outside the Immer.
state.items = state.items.concat(action.payload);  // ⚠️ The Immer handles the push

// Don't use the Date.now() for the ID.
id: Date.now(),  // ⚠️ The collision in the fast calls

// Don't forget the extraReducers for the thunk.
createAsyncThunk(...);  // ❌ The state is not updated

// Don't use the classic action types.
const ADD_TODO = 'ADD_TODO';  // ❌ The slice generates them

// Don't write the switch statement.
switch (action.type) { case 'ADD_TODO': ... }  // ❌ The slice generates it

// Don't return undefined from the reducer.
addTodo: (state, action) => {
  return undefined;  // ❌ The state is lost
},  // ❌

Common Pitfalls

PitfallWhy It HappensFix
The state not updatedThe Immer not usedMutate the state
The action type wrongThe wrong nameCheck the slice name
The payload wrongNo prepareAdd the prepare
The thunk state missingNo extraReducersAdd the cases
The ID collisionThe Date.now()Use nanoid()
The type errorsNo PayloadActionAdd the type
The reducer lostThe undefined returnReturn the state or mutate

Real-World Examples

1. The Basic Slice

const todosSlice = createSlice({ name: 'todos', initialState, reducers: { ... } });

2. The Immer Mutation

addTodo: (state, action) => { state.items.push(action.payload); }

3. The Prepare

prepare: (text) => ({ payload: { id: nanoid(), text } })

4. The PayloadAction

addTodo: (state, action: PayloadAction<string>) => { ... }

5. The Export

export const { addTodo } = todosSlice.actions;
export default todosSlice.reducer;

6. The Async Thunk

export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => { ... });

7. The extraReducers

builder.addCase(fetchTodos.fulfilled, (state, action) => { state.items = action.payload; })

8. The Dispatch

dispatch(addTodo('Learn Redux'));

9. The Selector

const todos = useAppSelector((state) => state.todos.items);

10. The Complete Pattern

const todosSlice = createSlice({ name: 'todos', initialState, reducers: { ... } });

Visual

The Slice

┌────────────────────────────────────────────────────────────┐
│  THE SLICE                                                 │
│                                                            │
│  createSlice({                                             │
│    name: 'todos',           ← the prefix                  │
│    initialState: { ... },   ← the state                   │
│    reducers: {              ← the handlers                │
│      addTodo: (state, action) => {                         │
│        state.items.push(action.payload);                   │
│      },                                                    │
│    },                                                      │
│  });                                                       │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  .actions  → { addTodo, removeTodo, ... }         │     │
│  │  .reducer  → the reducer function                 │     │
│  │  .name     → 'todos'                              │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Action

┌────────────────────────────────────────────────────────────┐
│  THE ACTION                                                │
│                                                            │
│  addTodo('Learn Redux')                                    │
│    ↓                                                       │
│  {                                                         │
│    type: 'todos/addTodo',                                  │
│    payload: 'Learn Redux',                                 │
│  }                                                         │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The type is the slice name and the reducer key.  │     │
│  │  The payload is the argument.                     │     │
│  │  The dispatch sends the action to the reducer.    │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The Reducer

┌────────────────────────────────────────────────────────────┐
│  THE REDUCER                                               │
│                                                            │
│  (state, action) => {                                      │
│    state.items.push(action.payload);                       │
│  }                                                         │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  The Immer wraps the state in the proxy.          │     │
│  │  The push is the "mutation".                      │     │
│  │  The Immer produces the new state.                │     │
│  │                                                   │     │
│  │  The state is the immutable, the code is the      │     │
│  │  readable.                                        │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The extraReducers

┌────────────────────────────────────────────────────────────┐
│  THE extraReducers                                         │
│                                                            │
│  extraReducers: (builder) => {                             │
│    builder                                                 │
│      .addCase(fetchTodos.pending, (state) => {             │
│        state.status = 'loading';                           │
│      })                                                    │
│      .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;                 │
│      });                                                   │
│  },                                                        │
│                                                            │
│  The createAsyncThunk produces the three actions.          │
│  The extraReducers handles them.                           │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
createSliceCreate the reducer and the actions
nameThe action type prefix
initialStateThe state
reducersThe handlers
extraReducersThe external handlers
.actionsThe creators
.reducerThe reducer
The ImmerThe immutable updates
PayloadAction<T>The typed action
createAsyncThunkThe async action

Key takeaways:

  • The createSlice generates the reducer and the action creators. The name is the prefix, the initialState is the state, and the reducers is the map of the handlers. The .actions and the .reducer are the exports.
  • The action is the plain object with the type and the payload. The type is the name and the / and the reducer key. The payload is the value.
  • The Immer lets the “mutation” code produce the immutable state. The state.items.push(...) looks like the mutation, and the library produces the new state. The developer writes the readable code, and the store is the immutable.
  • The prepare callback customizes the payload. The prepare runs when the action creator is called, and the reducer runs when the action is dispatched. The nanoid generates the unique IDs.
  • The extraReducers handles the external actions. The createAsyncThunk produces the pending, the fulfilled, and the rejected. The builder.addCase handles the three.
  • The PayloadAction<T> types the payload. The PayloadAction<string> types the payload as the string. The TypeScript infers the rest from the slice.
  • The reducer must be pure. The Immer lets the mutation code, but the reducer must not perform the side effects. The side effects belong to the thunk or the listener middleware.
  • The action creators are the API. The addTodo('text') is the call, and the dispatch sends the action. The component does not construct the action object by hand.

Remember: The slices, the actions, and the reducers are the Redux Toolkit’s answer to the classic boilerplate. The createSlice generates the reducer and the action creators from the one object, and the Immer produces the immutable state from the readable mutation code. The result is the store where the actions are the records, the reducers are the rules, and the slices are the domains.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!