Predictable State

Redux Toolkit

Redux Toolkit is the modern, official way to write Redux. It removes the boilerplate, includes Immer for immutable updates and ships devtools, middleware and RTK Query.

intermediate15 min readUpdated Sep 15, 2026
counterSlice.js
js
// features/counter/counterSlice.js
import { createSlice } from "@reduxjs/toolkit";

const counterSlice = createSlice({
  name: "counter",
  initialState: { value: 0 },
  reducers: {
    increment(state) {
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
  },
});

export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
Built on
Redux
Immutability
Immer, built in
Store setup
configureStore
Logic
createSlice
Async
createAsyncThunk, RTK Query
Devtools
Enabled by default

Why it matters

Why Redux still earns its place

Predictable by design

State changes only through actions and pure reducers, so every transition is traceable and testable.

Time-travel devtools

Inspect every action, diff the state and replay history, which makes hard bugs reproducible.

Batteries included

Immer, middleware, devtools and a data-fetching layer ship together, so there is far less wiring to write.

The big picture

The three ideas behind Redux

A single store, pure reducers and dispatched actions. Toolkit keeps the ideas and removes the ceremony.

The store

Single source

One object holds the application state, created with configureStore.

Reducers

Pure updates

Functions that take the current state and an action and return the next state.

Actions

Events

Plain objects describing what happened, dispatched from components and handled in reducers.

Redux Toolkit at a glance

The core of Toolkit

configureStore

Creates the store with sensible defaults and devtools enabled.

createSlice

Generates actions and reducers for a feature in one file.

useSelector

Read a slice of state and re-render only when that slice changes.

useDispatch

Send actions from components to update the store.

createAsyncThunk

Handle async work with pending, fulfilled and rejected states.

RTK Query

A built-in data-fetching and caching layer for server state.

A short history

From boilerplate to batteries included

  1. 2015

    Redux released

    A predictable state container inspired by Flux becomes the default React state tool.

    15
  2. 2019

    Redux Toolkit

    The official package removes boilerplate and adds Immer, devtools and middleware defaults.

    19
  3. 2021

    RTK Query

    A data-fetching and caching layer is added to the toolkit.

    21
  4. 2022

    Recommended default

    Toolkit becomes the only recommended way to write Redux, with legacy patterns discouraged.

    22
  5. Today

    Mature and stable

    A dependable choice for large applications with complex, shared client state.

    Today

The complete guide

Redux Toolkit: Everything you need to know

What is Redux Toolkit?

Redux is a predictable state container: a single store holds your application state, and the only way to change it is to dispatch an action that a pure reducer handles. That constraint is what makes Redux powerful — every state change is explicit, traceable and testable.

For years, Redux was also famous for boilerplate. Redux Toolkit fixed that. It is the official, recommended way to write Redux today, bundling configureStore, createSlice, Immer for immutable updates, thunk middleware and devtools into one package. You get the architecture without the ceremony.

The store and slices

A slice is a feature’s reducer logic and actions in one place. configureStore combines slices into the store.

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

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todos: todosReducer,
  },
});
// main.jsx
import { Provider } from "react-redux";
import { store } from "./app/store";

<Provider store={store}>
  <App />
</Provider>;

createSlice generates the action creators and the reducer for you, so you never hand-write action type constants. Immer powers the reducers, which means you can write code that looks like mutation while the state stays immutable.

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

const todosSlice = createSlice({
  name: "todos",
  initialState: [],
  reducers: {
    add(state, action) {
      state.push(action.payload);
    },
    toggle(state, action) {
      const todo = state.find((t) => t.id === action.payload);
      if (todo) todo.done = !todo.done;
    },
    remove(state, action) {
      return state.filter((t) => t.id !== action.payload);
    },
  },
});

export const { add, toggle, remove } = todosSlice.actions;
export default todosSlice.reducer;

Reading and dispatching

Components read state with useSelector and send actions with useDispatch.

// TodoList.jsx
import { useSelector, useDispatch } from "react-redux";
import { add, toggle } from "../features/todos/todosSlice";

export function TodoList() {
  const todos = useSelector((state) => state.todos);
  const dispatch = useDispatch();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} onClick={() => dispatch(toggle(todo.id))}>
          {todo.done ? "✓" : "○"} {todo.text}
        </li>
      ))}
      <button onClick={() => dispatch(add({ id: 1, text: "New" }))}>
        Add
      </button>
    </ul>
  );
}

Select the smallest slice you need. useSelector re-renders the component when the selected value changes, so selecting the whole state defeats the optimisation. Use createSelector from Reselect when a derived value would otherwise be recomputed on every render.

Async logic with thunks

createAsyncThunk generates the three action types an async request produces, which you handle in extraReducers.

// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchUsers = createAsyncThunk("users/fetch", async () => {
  const res = await fetch("/api/users");
  if (!res.ok) throw new Error("Failed to fetch users");
  return res.json();
});

const usersSlice = createSlice({
  name: "users",
  initialState: { items: [], status: "idle", error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = "loading";
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.items = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.error.message;
      });
  },
});

This is the explicit Redux pattern: loading, success and failure are all actions you can inspect in the devtools.

RTK Query for server state

For data fetching, RTK Query removes the thunks entirely. You define an API slice with endpoints, and it handles caching, deduplication, invalidation and refetching.

// features/api/apiSlice.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const api = createApi({
  reducerPath: "api",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Post"],
  endpoints: (builder) => ({
    getPosts: builder.query({ query: () => "posts", providesTags: ["Post"] }),
    addPost: builder.mutation({
      query: (body) => ({ url: "posts", method: "POST", body }),
      invalidatesTags: ["Post"],
    }),
  }),
});

export const { useGetPostsQuery, useAddPostMutation } = api;
// Posts.jsx
const { data, isLoading, error } = useGetPostsQuery();

RTK Query is the Redux answer to TanStack Query: keep server state in a cache with a lifecycle, and keep client state in slices. Mixing the two responsibilities in one place is where Redux apps get messy.

Middleware and devtools

configureStore enables the Redux DevTools extension and includes thunk middleware by default. You can add your own middleware for logging, analytics or side effects.

// middleware.js
const logger = (store) => (next) => (action) => {
  console.log("dispatching", action.type);
  return next(action);
};

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) => getDefault().concat(logger),
});

The devtools let you inspect every dispatched action, diff the state before and after, and replay history — the single biggest reason teams choose Redux for complex state.

Best practices

  • Use Redux Toolkit, never hand-written Redux.
  • Keep one slice per feature and put related logic together.
  • Select the smallest state slice in useSelector.
  • Use createSelector for derived data that is expensive to compute.
  • Keep server state in RTK Query, not in slices.
  • Store only serialisable data; keep non-serialisable values out of the store.
  • Model actions as events that happened, not as setters.

Common mistakes

  • Selecting the entire state and re-rendering on every change.
  • Putting server data in slices and managing loading flags by hand.
  • Storing non-serialisable values such as class instances or functions.
  • Mutating state outside a reducer.
  • Using Redux for local component state that useState would handle.
  • Writing verbose legacy Redux instead of slices.

Where to go next

Redux Toolkit is the mature choice for large, complex client state. Compare it with React’s built-in Context API and the lighter Zustand, and split server concerns into TanStack Query or RTK Query. For Vue, the equivalent role is filled by Pinia.

Writing a reducer

createSlice generates action creators and uses Immer so you can write mutating-looking code that stays immutable.

Toolkit
const slice = createSlice({
  name: "todos",
  initialState: [],
  reducers: {
    add(state, action) {
      state.push(action.payload);
    },
  },
});
Legacy Redux
function todos(state = [], action) {
  switch (action.type) {
    case "todos/add":
      return [...state, action.payload];
    default:
      return state;
  }
}
// plus action creators,
// constants and types

Reading state

Select the smallest slice you need so the component only re-renders when that value changes.

Prefer
const count = useSelector(
  (state) => state.counter.value,
);
Avoid
const state = useSelector(
  (state) => state,
);
// re-renders on any change

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Redux Toolkit?

Our interactive tutorial walks you through Redux Toolkit step by step — with quizzes and real code you can run in the browser.