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
stateyou receive. Never reassign the parameter itself (state = ...) — either mutate its properties orreturna 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
| API | Purpose |
|---|---|
configureStore | Creates the store with thunk middleware, dev checks, and DevTools preconfigured. |
createSlice | Generates reducers + action creators for one feature; uses Immer internally. |
createAsyncThunk | Handles async logic with pending/fulfilled/rejected lifecycle actions. |
createSelector | Builds memoized selectors to derive data without extra re-renders. |
useSelector | Reads a slice of state and re-renders on change. |
useDispatch | Returns 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
useSelectorselections 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
createSliceand avoid mixing areturnwith 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.