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
createSelectorfor 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
useStatewould 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.