| | |

Redux Toolkit 03 ⚛️ Creating Slices — Combining state, actions, and reducers

⏱️ Estimated read: 13 min | |

A slice is the fundamental unit of Redux Toolkit logic. It bundles three things that classic Redux kept in separate files: the initial state, the reducer functions that modify that state, and the action creators that trigger those reducers. The createSlice function accepts a name, an initial state, and a set of reducer functions, and returns a slice object containing the reducer, the action creators, and the action types—all generated automatically from the reducer names . This co-location is the core of Redux Toolkit’s ergonomics: one file defines everything a feature needs, and the store imports only the reducer.

The previous chapters created the store and connected it to React. This chapter fills the store with logic. Every slice created with createSlice is added to the reducer object in configureStore, and its state becomes a branch of the root state tree. The state shape, the action types, and the TypeScript inference all derive from the slice definition.

Key point: createSlice uses Immer internally, which means reducers can be written with mutating syntax—state.value += 1—and the library produces an immutable update behind the scenes. This is not a license to mutate state outside reducers; it is a convenience that applies only inside the reducers object, where Immer wraps the state in a draft that tracks mutations and produces a new immutable value.


Why slices replace the classic Redux triad

The action-type boilerplate problem.

Classic Redux required a separate constants file for action types, a separate file for action creators, and a separate file for the reducer. Each action required three definitions: const INCREMENT = 'counter/increment', const increment = () => ({ type: INCREMENT }), and a switch case in the reducer. createSlice generates all three from a single reducer function named increment. The action type becomes 'counter/increment' automatically, and the action creator is exported as slice.actions.increment .

The immutability problem.

Classic Redux reducers had to return a new state object manually: return { ...state, value: state.value + 1 }. For nested data, this required spreads at every level, and a missing spread silently mutated the existing state. createSlice uses Immer, so the reducer can be written as state.value += 1. Immer tracks the mutation on a draft object and produces a new immutable state with the correct references. The code is shorter and the immutability is guaranteed by the library rather than by developer discipline .

The TypeScript inference problem.

The PayloadAction<T> type describes an action with a typed payload. createSlice infers the action creators’ types from the reducer signatures, so slice.actions.incrementByAmount(5) is type-checked against the payload type declared in the reducer. The slice’s state type is defined once in the initialState, and the RootState type from the store includes it automatically when the reducer is added to configureStore .


a. The anatomy of a slice

A slice is defined in a feature folder, typically src/features/<name>/<name>Slice.ts. The createSlice call accepts a configuration object with three required fields: name, initialState, and reducers.

// src/features/counter/counterSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'

// Define the state shape as an interface
interface CounterState {
  value: number
  step: number
}

// The initial state must match the interface
const initialState: CounterState = {
  value: 0,
  step: 1,
}

const counterSlice = createSlice({
  name: 'counter',          // Prefix for action types: 'counter/increment'
  initialState,             // The state branch for this slice
  reducers: {
    // Each function becomes an action creator and a reducer case
    increment: (state) => {
      state.value += state.step    // Immer draft; mutation is safe
    },
    decrement: (state) => {
      state.value -= state.step
    },
    // PayloadAction<T> types the action creator's argument
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
    },
    setStep: (state, action: PayloadAction<number>) => {
      state.step = action.payload
    },
    reset: (state) => {
      state.value = 0
      state.step = 1
    },
  },
})

// Export the action creators
export const { increment, decrement, incrementByAmount, setStep, reset } =
  counterSlice.actions

// Export the reducer for the store
export default counterSlice.reducer

The name field determines the action type prefix. The increment reducer produces the action type 'counter/increment'. The action creator increment() returns { type: 'counter/increment' }. The reducer function is the case handler for that type. All of this is generated from the single function name .

The PayloadAction<number> type on incrementByAmount tells TypeScript that the action creator accepts a number. Calling incrementByAmount('5') produces a type error. The action.payload inside the reducer is typed as number, so no cast is needed.

The default export is the reducer function. It is imported into the store and added to the reducer object under the slice’s name.

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,   // state.counter is CounterState
  },
})

The key in the reducer object—counter—becomes the branch name in the root state. A selector reads it as state.counter.value. The key does not have to match the slice’s name, but keeping them consistent avoids confusion .


b. Immer’s mutation syntax and its boundaries

Inside the reducers object, the state argument is an Immer draft. It looks like the plain state object, but it records mutations and produces an immutable copy when the reducer returns.

reducers: {
  // Immer draft: direct assignment is safe
  increment: (state) => {
    state.value += 1                 // Produces a new state object
  },

  // Returning a new value replaces the state
  setValue: (state, action: PayloadAction<number>) => {
    return { ...state, value: action.payload }
  },

  // Arrays can be mutated with push/filter on the draft
  addTodo: (state, action: PayloadAction<string>) => {
    state.todos.push({ id: Date.now(), text: action.payload, done: false })
  },
}

The mutation syntax is safe only inside the reducers object. Outside the reducer, state is immutable, and mutating it directly causes bugs that the development-time middleware will warn about. The boundary is the reducer function: inside it, Immer handles immutability; outside it, the developer must use dispatch with action creators.

A subtle rule: a reducer must either mutate the draft or return a new value, not both. Returning undefined after mutating the draft is correct, because the mutation is the update. Returning a new object after mutating the draft produces an error because Immer cannot reconcile the two.


c. Adding slices to the store and inferring types

Each slice is added to the store’s reducer object. The RootState type, inferred from the store, automatically includes every slice’s state. The useAppSelector hook from the previous chapter then knows the full state shape.

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
import todosReducer from '../features/todos/todosSlice'
import userReducer from '../features/user/userSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer,
    user: userReducer,
  },
})

export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

The root state now has three branches: counter, todos, and user. A selector reads state.counter.value, state.todos.items, or state.user.name. The types are inferred; no manual root state interface is needed.

When a new slice is added, it is imported and added to the object. The RootState type updates automatically because it is derived from store.getState. Existing selectors that reference other branches continue to work, and the new branch is available to selectors that need it.


Complete Example Session

// ============================================
// PART 1: DEFINE THE STATE INTERFACE
// ============================================
// src/features/todos/todosSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'

interface Todo {
  id: number
  text: string
  done: boolean
}

interface TodosState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
}

// ============================================
// PART 2: DEFINE THE INITIAL STATE
// ============================================
const initialState: TodosState = {
  items: [],
  filter: 'all',
}

// ============================================
// PART 3: CREATE THE SLICE
// ============================================
const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
// ============================================
// PART 4: ARRAY MUTATION WITH IMMER
// ============================================
    // Immer allows push on the draft
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({
        id: Date.now(),
        text: action.payload,
        done: false,
      })
    },

// ============================================
// PART 5: UPDATING AN ARRAY ELEMENT
// ============================================
    // find returns the draft element; mutating it is safe
    toggleTodo: (state, action: PayloadAction<number>) => {
      const todo = state.items.find((item) => item.id === action.payload)
      if (todo) {
        todo.done = !todo.done
      }
    },

// ============================================
// PART 6: REMOVING AN ARRAY ELEMENT
// ============================================
    // splice mutates the draft array
    removeTodo: (state, action: PayloadAction<number>) => {
      const index = state.items.findIndex((item) => item.id === action.payload)
      if (index !== -1) {
        state.items.splice(index, 1)
      }
    },

// ============================================
// PART 7: UPDATING A SIMPLE VALUE
// ============================================
    setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
      state.filter = action.payload
    },
  },
})

// ============================================
// PART 8: EXPORT ACTIONS AND REDUCER
// ============================================
export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions
export default todosSlice.reducer

// ============================================
// PART 9: ADD TO THE STORE
// ============================================
// src/app/store.ts
// import todosReducer from '../features/todos/todosSlice'
//
// export const store = configureStore({
//   reducer: {
//     todos: todosReducer,
//   },
// })

// ============================================
// PART 10: CONSUME IN A COMPONENT
// ============================================
// function TodoList() {
//   const items = useAppSelector((state) => state.todos.items)
//   const filter = useAppSelector((state) => state.todos.filter)
//   const dispatch = useAppDispatch()
//
//   return (
//     <ul>
//       {items.map((todo) => (
//         <li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
//           {todo.text}
//         </li>
//       ))}
//     </ul>
//   )
// }

The ten parts demonstrate the complete slice definition: the state interface, the initial state, the reducer functions with Immer mutations, the exported action creators, the reducer export, the store integration, and the component consumption. The Immer syntax in Parts 4–6 uses push, direct property assignment, and splice—all mutations that would be forbidden outside a reducer but are safe inside the Immer draft.


Quick Reference

createSlice Configuration

FieldRequiredPurpose
nameYesPrefix for action types
initialStateYesState branch for the slice
reducersYesObject of reducer functions
extraReducersNoHandlers for external actions

Generated Artifacts

ArtifactAccessExample
Action creatorsslice.actions.<name>todosSlice.actions.addTodo
Action type'<name>/<reducerName>''todos/addTodo'
Reducerslice.reducerDefault export
Slice nameslice.name'todos'

Immer Rules Inside Reducers

OperationAllowed
state.value = xYes
state.items.push(x)Yes
state.items.splice(i, 1)Yes
return { ...state, value: x }Yes (instead of mutation)
Mutate and return a new valueNo
Mutate state outside reducerNo

Best Practices

✅ Do This:

// Define the state interface separately
interface CounterState { value: number }                       // ✅

// Use PayloadAction for typed payloads
incrementBy: (state, action: PayloadAction<number>) => ...     // ✅

// Mutate the Immer draft directly
state.value += 1                                               // ✅

// Export action creators individually
export const { increment, decrement } = slice.actions          // ✅

// Export the reducer as default
export default slice.reducer                                   // ✅

// Add the reducer to configureStore
reducer: { counter: counterReducer }                           // ✅

❌ Don’t Do This:

// Mutate state outside the reducer
store.getState().counter.value = 5                             // ❌

// Mutate and return a new value in the same reducer
state.value = 5; return { ...state, other: 1 }                 // ❌

// Use untyped action.payload
(state, action) => state.value += action.payload               // ❌ (implicit any)

// Forget to add the reducer to the store
// Slice exists but state.counter is undefined                 // ❌

// Export the slice object instead of its reducer
export default counterSlice    // Wrong for store import        // ❌

// Change the store key without updating selectors
reducer: { count: counterReducer }    // state.counter is undefined // ❌

Common Pitfalls

PitfallWhy It HappensFix
state.counter is undefinedReducer not added to storeImport and add to reducer object
Action type mismatchname field changedAction types are derived from name
Immer error “mutate and return”Reducer both mutates and returnsChoose one pattern
action.payload is anyMissing PayloadAction<T>Add the type parameter
State not updating in UIMutating state outside reducerDispatch action creators
Slice imported but not usedReducer not registeredAdd to configureStore

Real-World Examples

1. Minimal counter slice

const slice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: { increment: (state) => { state.value += 1 } },
})
export const { increment } = slice.actions
export default slice.reducer

2. Slice with typed payload

incrementByAmount: (state, action: PayloadAction<number>) => {
  state.value += action.payload
}

3. Array push with Immer

addTodo: (state, action: PayloadAction<string>) => {
  state.items.push({ id: Date.now(), text: action.payload })
}

4. Array filter with return

clearCompleted: (state) => {
  return { ...state, items: state.items.filter((t) => !t.done) }
}

5. Toggle nested object

toggle: (state, action: PayloadAction<number>) => {
  const item = state.items.find((i) => i.id === action.payload)
  if (item) item.done = !item.done
}

6. Reset to initial state

reset: () => initialState

7. Export actions

export const { addTodo, toggleTodo } = todosSlice.actions

8. Add to store

reducer: { todos: todosReducer }

9. Selector for slice state

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

10. Multiple slices in store

reducer: { counter: counterReducer, todos: todosReducer, user: userReducer }

Visual

The Slice Anatomy

┌──────────────────────────────────────────────────────────────┐
│  createSlice BUNDLES STATE, REDUCERS, AND ACTIONS            │
│                                                              │
│  createSlice({                                               │
│    name: 'counter',                                          │
│    initialState: { value: 0 },                               │
│    reducers: {                                               │
│      increment: (state) => { state.value += 1 },             │
│      decrement: (state) => { state.value -= 1 },             │
│    },                                                        │
│  })                                                          │
│       │                                                      │
│       ▼                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Returned Slice Object                                  │  │
│  │                                                        │  │
│  │ .name      → 'counter'                                 │  │
│  │ .reducer   → function(state, action)                   │  │
│  │ .actions   → { increment, decrement }                  │  │
│  │              Action types: 'counter/increment',        │  │
│  │                           'counter/decrement'          │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  All three are derived from the reducer names.               │
│  No manual action type constants.                            │
└──────────────────────────────────────────────────────────────┘

Immer Draft Workflow

┌──────────────────────────────────────────────────────────────┐
│  IMMER TURNS MUTATIONS INTO IMMUTABLE UPDATES                │
│                                                              │
│  Current state (immutable):                                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ { value: 0, step: 1 }                                  │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           ▼                                  │
│  Immer creates a draft:                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ draft = { value: 0, step: 1 }  (proxy)                 │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           ▼                                  │
│  Reducer mutates the draft:                                  │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ draft.value += 1                                       │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           ▼                                  │
│  Immer produces a new immutable state:                       │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ { value: 1, step: 1 }   ← new reference                │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  The original state is not modified.                         │
└──────────────────────────────────────────────────────────────┘

Slice to Store to Component

┌──────────────────────────────────────────────────────────────┐
│  FROM SLICE DEFINITION TO COMPONENT SUBSCRIPTION             │
│                                                              │
│  counterSlice.ts                                             │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ createSlice({ name: 'counter', ... })                  │  │
│  │ export default counterSlice.reducer                    │  │
│  │ export const { increment } = counterSlice.actions      │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  store.ts                                                    │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ configureStore({ reducer: { counter: counterReducer }})│  │
│  │ → RootState includes { counter: CounterState }         │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  Component.tsx                                               │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ const value = useAppSelector((s) => s.counter.value)   │  │
│  │ const dispatch = useAppDispatch()                      │  │
│  │ dispatch(increment())                                  │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
FunctioncreateSlice({ name, initialState, reducers })
Action type format'<name>/<reducerName>'
Action creatorsslice.actions.<reducerName>
Reducer exportslice.reducer
State mutationAllowed inside reducers via Immer draft
Payload typingPayloadAction<T>
Store integrationAdd reducer to configureStore reducer object
Immer limitationMutate or return, not both
RootState updateAutomatic when reducer is added

Key takeaways:

  • A slice bundles state, reducers, and action creators. The name field prefixes the action types, the initialState defines the state branch, and the reducers object generates both the reducer cases and the action creators .
  • Immer allows mutating syntax inside reducers. state.value += 1 and state.items.push(...) are safe because Immer wraps the state in a draft and produces an immutable copy. This convenience applies only inside the reducers object .
  • PayloadAction<T> types the action creator’s argument. The payload type is declared in the reducer signature, and TypeScript enforces it at the call site. action.payload inside the reducer is typed accordingly.
  • A reducer must mutate or return, not both. Returning undefined after mutating the draft is correct. Returning a new value is also correct. Doing both produces an Immer error.
  • The slice reducer is added to the store under a key. The key becomes the state branch name. A selector reads state.<key>.<field>. The RootState type updates automatically when the reducer is added to configureStore.
  • Action creators are exported individually. export const { increment, decrement } = slice.actions gives components named functions to dispatch. The default export is the reducer, imported by the store.
  • The slice is the unit of feature logic. One file defines the state shape, the update logic, and the action creators for a feature. The store composes the slices, and the components dispatch the actions and select the state.

Remember: A slice is a self-contained description of a piece of state and the operations that modify it. createSlice generates the action types, the action creators, and the reducer from a single object of reducer functions. Immer makes the reducer code look like mutation while producing immutable updates. The slice’s reducer is added to configureStore, and its state becomes a branch of RootState. The action creators are exported and dispatched from components. Get the slice shape right, and the store composition and component subscriptions follow.


Was this helpful?


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!