Skip to content
React rc state-management 5 min read

Redux Toolkit

Redux Toolkit (RTK) is the official, recommended way to write Redux logic. Classic Redux earned a reputation for boilerplate — action types, action creators, switch-statement reducers, and immutable update gymnastics. RTK collapses all of that into a few focused APIs (configureStore, createSlice, createAsyncThunk) that ship with sensible defaults, built-in Immer for “mutating” syntax, and the Redux DevTools wired up out of the box. If you need predictable, debuggable global state that scales across a large app, RTK is the modern answer.

When to reach for Redux Toolkit

Redux shines when state is genuinely global, updated from many places, and benefits from a strict, traceable update flow. Think authenticated user sessions, shopping carts, multi-step wizards, or cached server data shared by distant components. For purely local UI state, useState is still the right tool — reach for RTK when prop drilling and ad-hoc context start to hurt.

Installation

Install the toolkit alongside the React bindings:

npm install @reduxjs/toolkit react-redux

@reduxjs/toolkit provides the store and slice APIs; react-redux provides the <Provider> component and the useSelector / useDispatch hooks.

Creating a slice

A slice is a self-contained bundle of reducer logic and the actions that drive it for a single feature. createSlice generates the action creators and action types for you from the reducer names.

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

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment(state) {
      state.value += 1;        // looks mutable — Immer makes it safe
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementBy(state, action) {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementBy } = counterSlice.actions;
export default counterSlice.reducer;

The “mutating” code above is not really mutating anything. RTK runs your reducer inside Immer, which records the changes you make to a draft and produces a brand-new immutable state object behind the scenes.

Immer only tracks the draft state you receive. Never reassign the parameter itself (state = ...) — either mutate its properties or return a new value, never both.

Configuring the store

configureStore wires your slice reducers together, adds the redux-thunk middleware, enables development-time checks (immutability and serializability), and connects the Redux DevTools extension — all automatically.

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counterSlice';
import todosReducer from '../features/todosSlice';

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

The keys in reducer define the shape of your global state: state.counter and state.todos.

Providing the store

Wrap your app in <Provider> once, at the root, so every component can read from and dispatch to the store.

// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </StrictMode>
);

Reading and dispatching with hooks

useSelector subscribes a component to a slice of state and re-renders it only when that selected value changes. useDispatch returns the store’s dispatch function so you can fire actions.

// Counter.jsx
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementBy } from './features/counterSlice';

export default function Counter() {
  const value = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <section>
      <h2>Count: {value}</h2>
      <button onClick={() => dispatch(decrement())}>-1</button>
      <button onClick={() => dispatch(increment())}>+1</button>
      <button onClick={() => dispatch(incrementBy(5))}>+5</button>
    </section>
  );
}

A todos slice with payloads

Action payloads carry data into the reducer. Here each todo gets a unique id, and toggling flips its done flag.

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

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    addTodo: {
      reducer(state, action) {
        state.items.push(action.payload);
      },
      prepare(text) {
        return { payload: { id: nanoid(), text, done: false } };
      },
    },
    toggleTodo(state, action) {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.done = !todo.done;
    },
    removeTodo(state, action) {
      state.items = state.items.filter((t) => t.id !== action.payload);
    },
  },
});

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

The prepare callback lets you customize how arguments map to the action payload — perfect for generating ids or timestamps.

// Todos.jsx
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, removeTodo } from './features/todosSlice';

export default function Todos() {
  const items = useSelector((state) => state.todos.items);
  const dispatch = useDispatch();
  const [text, setText] = useState('');

  function handleAdd() {
    if (!text.trim()) return;
    dispatch(addTodo(text.trim()));
    setText('');
  }

  return (
    <section>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
      <ul>
        {items.map((t) => (
          <li key={t.id}>
            <span
              onClick={() => dispatch(toggleTodo(t.id))}
              style={{ textDecoration: t.done ? 'line-through' : 'none' }}
            >
              {t.text}
            </span>
            <button onClick={() => dispatch(removeTodo(t.id))}>x</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Clicking +5 twice then adding two todos and toggling the first produces this state, visible in the Redux DevTools:

Output:

counter: { value: 10 }
todos: {
  items: [
    { id: "a1b2c3", text: "Write docs",  done: true  },
    { id: "d4e5f6", text: "Ship release", done: false }
  ]
}

Core APIs at a glance

APIPurpose
configureStoreCreates the store with thunk middleware, dev checks, and DevTools preconfigured.
createSliceGenerates reducers + action creators for one feature; uses Immer internally.
createAsyncThunkHandles async logic with pending/fulfilled/rejected lifecycle actions.
createSelectorBuilds memoized selectors to derive data without extra re-renders.
useSelectorReads a slice of state and re-renders on change.
useDispatchReturns dispatch to send actions to the store.

Best practices

  • Organize code by feature using the “ducks” pattern — keep each slice’s reducers, actions, and selectors in one file.
  • Keep useSelector selections narrow and specific so components re-render only when the data they actually use changes.
  • Co-locate and export reusable selectors (memoized with createSelector) instead of duplicating selection logic across components.
  • Never mutate state outside a reducer; rely on Immer inside createSlice and avoid mixing a return with draft mutation.
  • Use createAsyncThunk (see Redux async logic) or RTK Query for data fetching rather than hand-rolled middleware.
  • Store only serializable values (no class instances, Promises, or functions) so time-travel debugging and persistence keep working.
Last updated June 14, 2026
Was this helpful?