State Management – Redux
Build a Todo App. Understand Redux Along the Way.
1. Install the packages and open a fresh project
Let's build something you can use today: a todo list where you can add tasks, mark them complete, remove them, and filter what you see.
Start in your terminal:
npm create vite@latest redux-todos -- --template react
cd redux-todos
npm install
npm install @reduxjs/toolkit react-redux
npm run dev
Open the local URL printed in the terminal. Use a Node.js version supported by the current Vite release; if the installer reports a version mismatch, update Node first.
Already have a browser-rendered React project? Install the two Redux packages there and follow the same src files below.
What did we install? @reduxjs/toolkit gives us the tools to build a Redux store and describe its updates. react-redux connects that store to React components. Redux Toolkit includes Redux itself.
We'll use JavaScript first so you can concentrate on what Redux does. Modern Redux does not require TypeScript; we'll discuss the TypeScript upgrade once the flow makes sense.
For now, you don't need to know what a store or reducer is. We'll give each one a job as we build.
2. Meet the app we're building
Picture a small board titled My next small win. You type “Learn Redux,” press Add, and see a new checkbox. Above the list, a counter says 1 task left.
Tick the checkbox. The row changes, and the counter becomes 0 tasks left. Switch to the Active filter, and the completed row disappears. Switch to All, and it's still there.
Those interactions all use the same task data. The form creates tasks, the list displays them, and the counter summarizes them. Redux will give that shared data one owner.
A small todo app could also work with local React state. We're using it because the consequences of each Redux update are easy to see—not because every todo app needs Redux.
Insert image here — Show the destination before the machinery
Image prompt: Create a clean UI mockup of a todo app titled “My next small win.” Show an input containing “Learn Redux,” an Add task button, filter buttons All / Active / Completed, and two rows: “Read one chapter” checked and “Learn Redux” unchecked. Show “1 task left · 2 total.” Use restrained blue accents, generous spacing, and clear readable labels. Caption: “One task list powers the form, the rows, and the summary.” This is an interface illustration; do not show Redux implementation details yet.
We'll create four files. Keep the Vite-generated index.html and its root element.
File | Its job in our story |
|---|---|
| Remember tasks and define their update rules |
| Create the shared Redux store |
| Display the board and report user interactions |
| Connect the store to the React tree |
Create the features/todos folders inside src. Replace the generated App.jsx and main.jsx with the examples below. The app works without a backend, account, or stylesheet.
3. Give the tasks a home and some rules
Before writing a component, decide what one task looks like:
const exampleTodo = {
id: 'task-1',
title: 'Learn Redux',
completed: false
};
The ID identifies the task even if its title changes. completed remembers whether its checkbox is checked.
Our shared state will hold an array of these tasks and the selected filter:
const exampleTodoState = {
items: [],
filter: 'all'
};
Here is the file that owns those values.
File: src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
items: [],
filter: 'all'
};
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action) {
state.items.push({
id: action.payload.id,
title: action.payload.title,
completed: false
});
},
todoToggled(state, action) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
todoRemoved(state, action) {
if (!state.items.some((item) => item.id === action.payload)) return;
state.items = state.items.filter((item) => item.id !== action.payload);
},
filterChanged(state, action) {
state.filter = action.payload;
},
completedCleared(state) {
if (!state.items.some((item) => item.completed)) return;
state.items = state.items.filter((item) => !item.completed);
}
}
});
export const {
todoAdded,
todoToggled,
todoRemoved,
filterChanged,
completedCleared
} = todosSlice.actions;
export const selectTodos = (state) => state.todos.items;
export const selectFilter = (state) => state.todos.filter;
export const selectRemainingCount = (state) =>
state.todos.items.filter((todo) => !todo.completed).length;
export default todosSlice.reducer;
Read this file as a set of decisions
initialState answers “What do we remember before anyone uses the app?” No tasks, and the All filter.
createSlice groups one feature's state and update logic. A slice is a portion of the application's state. Ours is the todo feature.
name: 'todos' supplies a prefix for generated action names. It will make events such as todos/todoAdded recognizable when we inspect the app.
Each function inside reducers describes one update:
todoAddedputs a new, incomplete task into the array.todoToggledfinds one task by ID and flips its completion value.todoRemovedremoves the matching task; an unknown ID leaves state alone.filterChangedremembers which view the user selected.completedClearedremoves completed tasks without touching active ones.
These update functions are case reducers. Toolkit builds the full slice reducer from them. A reducer receives previous state and an action and calculates the next state.
The todosSlice.actions export gives us action creators. Calling todoToggled('task-1') creates an event object describing the requested interaction:
const exampleAction = {
type: 'todos/todoToggled',
payload: 'task-1'
};
type identifies the event. payload carries its extra information. Not every action needs a payload—clearing completed tasks needs no additional data.
The selector exports at the bottom answer three reading questions: Which tasks exist? Which filter is selected? How many tasks remain? A selector is just a function that reads or derives a value from state.
Finally, todosSlice.reducer is the update function we will register with the store. The slice object itself is a collection of generated tools; it is not what we store as the task data.
Wait—why does the reducer use push?
Redux updates must leave previous state snapshots unchanged. Toolkit's createSlice uses Immer, which lets these handlers edit a draft and then produces an immutable next state.
That permission applies inside these Immer-backed reducers. It does not mean a component can take a selected array and mutate it.
Also keep reducers focused on calculating state. No requests, timers, storage writes, random IDs, or current-time generation here. The UI will generate an ID before sending an add event.
4. Create the shared store
Our task rules exist, but nothing holds the application's current state yet.
File: src/store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';
export const store = configureStore({
reducer: {
todos: todosReducer
}
});
A store holds the current Redux state and coordinates updates. configureStore creates it with useful defaults, including middleware and Redux DevTools integration.
The key todos tells Redux where this feature's state lives. The complete state starts as:
const exampleRootState = {
todos: {
items: [],
filter: 'all'
}
};
That is why our selectors read state.todos.items rather than state.items.
There are two distinct names here: the store's reducer-map key controls the state path; the slice's name controls its generated action prefix. We use todos for both to keep the story easy to follow.
5. Let React use that store
File: src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('Missing root element');
createRoot(root).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
Provider makes the store available to React-Redux consumers underneath it. Our form, task list, and summary can now use that same store.
It does not copy the state into each component. It supplies the store that those components read and dispatch to.
Keep the Vite development server running. The next file gives the app its visible behavior.
6. Build the board
We'll use two Hooks from React-Redux:
useSelector reads a selected value and subscribes the component to store updates.
useDispatch gives the component the store's dispatch function. Dispatch sends an event into Redux's update pipeline.
Now the complete UI:
File: src/App.jsx
import { useState } from 'react';
import { nanoid } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
import {
todoAdded,
todoToggled,
todoRemoved,
filterChanged,
completedCleared,
selectTodos,
selectFilter,
selectRemainingCount
} from './features/todos/todosSlice';
function AddTodoForm() {
const [title, setTitle] = useState('');
const dispatch = useDispatch();
function handleSubmit(event) {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
dispatch(todoAdded({ id: nanoid(), title: trimmedTitle }));
setTitle('');
}
return (
<form
<label>
Your next task
<input
value={title} => setTitle(event.target.value)}
placeholder="Learn Redux"
/>
</label>
<button type="submit" disabled={!title.trim()}>Add task</button>
</form>
);
}
function TodoSummary() {
const remaining = useSelector(selectRemainingCount);
const total = useSelector((state) => state.todos.items.length);
return (
<p aria-live="polite">
{remaining} {remaining === 1 ? 'task' : 'tasks'} left · {total} total
</p>
);
}
function TodoFilters() {
const filter = useSelector(selectFilter);
const dispatch = useDispatch();
return (
<div aria-label="Filter tasks">
{['all', 'active', 'completed'].map((value) => (
<button
key={value}
type="button"
aria-pressed={filter === value} => dispatch(filterChanged(value))}
>
{value[0].toUpperCase() + value.slice(1)}
</button>
))}
<button type="button" => dispatch(completedCleared())}>
Clear completed
</button>
</div>
);
}
function TodoRow({ todo }) {
const dispatch = useDispatch();
return (
<li>
<label>
<input
type="checkbox"
checked={todo.completed} => dispatch(todoToggled(todo.id))}
/>
{todo.completed ? <s>{todo.title}</s> : todo.title}
</label>
<button
type="button"
aria-label={`Remove ${todo.title}`} => dispatch(todoRemoved(todo.id))}
>
Remove
</button>
</li>
);
}
function TodoList() {
const todos = useSelector(selectTodos);
const filter = useSelector(selectFilter);
const visibleTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
if (visibleTodos.length === 0) {
return <p>No tasks in this view. Add one or try another filter.</p>;
}
return (
<ul>
{visibleTodos.map((todo) => <TodoRow key={todo.id} todo={todo} />)}
</ul>
);
}
export default function App() {
return (
<main>
<h1>My next small win</h1>
<AddTodoForm />
<TodoSummary />
<TodoFilters />
<TodoList />
</main>
);
}
You now have a working Redux app
Add “Learn Redux” and “Build a small project.” Check the first task. You should see 1 task left · 2 total. Select Active: only the second task remains visible. Select All: both return. Press Clear completed: only the active task remains in the data.
If an empty screen appears, check your imports, file names, and terminal errors. main.jsx must import the store and wrap App with Provider. The selector path must match the key registered in store.js.
Why the text field uses useState
While you type, the draft title belongs to the form. Other components do not need every keystroke. Only when you submit does it become a shared task.
This is a useful boundary: local state remembers the draft; Redux remembers the submitted task. Redux and React state are designed to coexist.
Why the form calls todoAdded inside dispatch
todoAdded(...) constructs an action. dispatch(...) sends it to the store. Creating an action alone does not change state.
nanoid() generates the ID during the interaction. The reducer receives that ID as data, which keeps its calculation deterministic.
Why the checkbox sends an ID
The row knows which task the user touched. It does not need to implement the update rules itself. It reports that task's ID; the slice finds the stored record and changes it.
The same event could come from a keyboard shortcut or another screen. The completion rule would still live in one place.
Why the counter is calculated
We store tasks and their completion values. We do not also store a separate remaining count that every update must synchronize.
The selector computes the count from the current tasks. If you remove a task, the answer changes naturally because its source data changed.
Why filtering does not delete anything
visibleTodos is a display result. Selecting Active changes filter; it does not remove completed tasks from items.
Notice that .filter() runs after selecting the stored array in this version. We are not asking useSelector to return a fresh filtered array on every store notification. Later, we'll move that calculation into a memoized selector.
7. Follow “Learn Redux” through the entire system
Slow down one interaction. The empty app is on screen. You type “Learn Redux” and submit.
First, the form reads its local draft. It trims the title and generates an ID.
Next, it creates an action. For an illustrative generated ID task-1, the action contains:
const exampleAddAction = {
type: 'todos/todoAdded',
payload: { id: 'task-1', title: 'Learn Redux' }
};
Dispatch sends that action through middleware. Middleware is logic around dispatch; it can handle effects or intercept events. Toolkit installs a useful default set. Our ordinary add action continues to the reducer.
The todo reducer applies the add rule. It puts a new task into the next state with completed: false. Redux saves that next state.
React-Redux observes the selected results. The list has a new item array. The remaining count is now 1. Relevant consumers update, and React renders the new row and count.
For an ordinary action reaching the base store dispatch, reducer calculation is synchronous. React's rendering is a separate part of displaying that updated state.
Insert image here — Trace one real event
Image prompt: Create a readable educational diagram titled “What happens when you add Learn Redux?” Start with AddTodoForm holding local title “Learn Redux.” Showdispatch(todoAdded({ id: "task-1", title: "Learn Redux" }))producing a plain action. Show middleware forwarding that action, then the previous empty todo state and action entering the slice reducer. Show next state containing{ id: "task-1", title: "Learn Redux", completed: false }. End with TodoList displaying the row and TodoSummary displaying “1 task left · 1 total.” Caption: “The UI reports an event; the reducer calculates state; selectors read the result.” Do not imply useSelector changes state or every Redux consumer must render.
The names now describe things you've already used:
Name | The concrete thing you just saw |
|---|---|
State | Tasks and the current filter |
Slice | The todo feature's state and update logic |
Store | The object holding current application state |
Action | The event object describing the add |
Action creator |
|
Dispatch | Sends the event into the store pipeline |
Reducer | Applies the rule that creates the stored task |
Selector | Reads the task list or calculates the remaining count |
Provider | Makes the store available to the React tree |
8. The board grows: make derived work reusable
A week later, the board has a search panel, a progress card, and several lists. Different screens repeat the same filtering logic. We can give that calculation a name and reuse it.
Toolkit exports createSelector, which memoizes a derived result from input-selector results.
Add file: src/features/todos/todoSelectors.js
import { createSelector } from '@reduxjs/toolkit';
import { selectTodos, selectFilter } from './todosSlice';
export const selectVisibleTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
if (filter === 'active') return todos.filter((todo) => !todo.completed);
if (filter === 'completed') return todos.filter((todo) => todo.completed);
return todos;
}
);
export const selectTodoProgress = createSelector(
[selectTodos],
(todos) => {
const completed = todos.filter((todo) => todo.completed).length;
return { total: todos.length, completed, remaining: todos.length - completed };
}
);
Import selectVisibleTodos into App.jsx. In TodoList, replace the two selector calls and the filtering calculation with:
const visibleTodos = useSelector(selectVisibleTodos);
Leave its empty-state check and list rendering as they are.
The input selectors extract stored values. The result function calculates the output. When those input-selector results are unchanged, the memoized selector can reuse its previous result. Define it outside the component so the selector instance survives renders.
This helps with two different costs: repeated calculations and unnecessarily new array/object results. It does not make a genuinely changed task list free to process.
Selected-result equality explains many “extra renders”
By default, useSelector compares its previous and next selected results with ===. Two equal numbers compare equal. Two separately created objects do not, even if their fields match.
If a consumer needs a combined progress object, use selectTodoProgress. Other options include separate scalar selections or React-Redux's shallowEqual, when shallow comparison fits the result.
Stable selected results avoid updates caused by that subscription. A component may still render through its own state, context, or a parent update.
Insert image here — Reuse a calculation when its inputs stay the same
Image prompt: Create two update scenarios for selectVisibleTodos. First: “Unrelated theme update,” showing the same todo array and filter “active” entering the memoized selector, which reuses “Visible array A.” Second: “Task completion changes,” showing a changed todo array entering the selector, which recalculates “Visible array B.” Caption: “Reuse depends on the calculation's inputs.” Add a note: “This avoids a subscription-driven update when the selected result stays equal; other React render causes still exist.” Do not invent timing numbers or imply a new filter always causes a different result reference.
Help unchanged rows reuse rendering work
Suppose one row has a costly preview. Completing one task changes that task object, but Immer can preserve references for the other task objects.
In App.jsx, add memo to the React import. After the TodoRow function, add:
const MemoTodoRow = memo(TodoRow);
Render <MemoTodoRow key={todo.id} todo={todo} /> instead of <TodoRow ... />.
Because the row receives a todo object, unchanged rows can have unchanged props. React's memo can then reuse their rendering work. Keep props stable: passing a new wrapper object or a new parent-created callback on every render can defeat that comparison.
Memoization is an optimization, not a correctness requirement. Measure slow interactions before adding it everywhere.
9. More tasks: organize records by ID
The next request is a details panel that can open any task by ID. With an array, we search for the task. For a larger, frequently edited collection, an ID lookup can simplify that work.
createEntityAdapter maintains a normalized collection: an ids array and an entities lookup. It also supplies CRUD helpers and selectors.
Here is an alternative local slice, not a second copy of the same tasks. If you adopt it, the earlier array selectors and components must be adapted to its new shape.
Independent example: src/advanced/normalizedTodos.js
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
export const todosAdapter = createEntityAdapter();
const normalizedTodosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState({ filter: 'all' }),
reducers: {
todoAdded: todosAdapter.addOne,
todoRemoved: todosAdapter.removeOne,
todoToggled(state, action) {
const todo = state.entities[action.payload];
if (todo) todo.completed = !todo.completed;
}
}
});
export const { todoAdded, todoRemoved, todoToggled } =
normalizedTodosSlice.actions;
export const normalizedTodoSelectors = todosAdapter.getSelectors(
(state) => state.todos
);
export default normalizedTodosSlice.reducer;
In this alternative, adding a todo supplies the complete record, including completed. A selector can use normalizedTodoSelectors.selectById(state, id) to retrieve it. Handle undefined if a task is missing or has been removed.
A list can select IDs, while each row selects its own record. Changing one record can leave the ID list and unrelated records stable, reducing the scope of subscription-driven updates. Normalization helps the data model; it does not replace measuring the UI's actual bottleneck.
Insert image here — Find one task without duplicating the collection
Image prompt: Create an illustration of normalized todo state. Showids: ["task-1", "task-2"]beside an entities table: task-1 / Learn Redux / false; task-2 / Build a small project / true. Show a list consuming the IDs and a details panel selecting task-1 from the lookup. Then show task-1 completed changing to true, while task-2 stays the same. Caption: “One record per ID; select only the record you need.” Do not imply normalization automatically prevents every component render or guarantees a measured speedup.
10. Make the app remember work between visits
Our first board works, but refreshing the page clears it. Redux holds state in memory; persistence is a separate feature.
Let's save the local task array after edits, using listener middleware. A listener runs work in response to actions or state changes, outside the reducer.
Add file: src/todoPersistence.js
import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit';
import {
todoAdded,
todoToggled,
todoRemoved,
completedCleared
} from './features/todos/todosSlice';
export const todoListener = createListenerMiddleware();
export function readSavedTodos() {
try {
const value = JSON.parse(localStorage.getItem('todo-items') ?? '[]');
if (!Array.isArray(value)) return [];
if (!value.every((todo) =>
todo &&
typeof todo.id === 'string' &&
typeof todo.title === 'string' &&
typeof todo.completed === 'boolean'
)) return [];
if (new Set(value.map((todo) => todo.id)).size !== value.length) return [];
return value.map(({ id, title, completed }) => ({ id, title, completed }));
} catch {
return [];
}
}
todoListener.startListening({
matcher: isAnyOf(todoAdded, todoToggled, todoRemoved, completedCleared),
effect: async (_action, listenerApi) => {
listenerApi.cancelActiveListeners();
await listenerApi.delay(400);
try {
localStorage.setItem(
'todo-items',
JSON.stringify(listenerApi.getState().todos.items)
);
} catch {
console.warn('Tasks could not be saved in this browser.');
}
}
});
Replace src/store.js for this local persistence version:
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './features/todos/todosSlice';
import { todoListener, readSavedTodos } from './todoPersistence';
export const store = configureStore({
reducer: { todos: todosReducer },
preloadedState: {
todos: { items: readSavedTodos(), filter: 'all' }
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(todoListener.middleware)
});
Here's the sequence: an edit happens, the listener waits 400 milliseconds, and another edit cancels the earlier wait. After edits settle, it writes the latest array. That's debouncing, used here to avoid writing on every quick interaction.
preloadedState supplies restored data when creating the store. Our reader checks the stored shape and handles invalid JSON. Saving and restoring are separate jobs; adding a storage write alone does not implement restoration.
The 400 milliseconds are an example policy, not a guarantee that the latest edit survives closing the tab immediately. This version saves tasks in this browser; it does not synchronize different devices.
Insert image here — Keep side effects outside the reducer
Image prompt: Create a two-lane timeline titled “Save after editing settles.” Top lane: three quick task edits update Redux state immediately. Bottom lane: listener wait A starts, the next edit cancels A and starts B, the third cancels B and starts C; C finishes after 400 milliseconds and writes the latest task array to localStorage. Show a later app start reading and validating stored data before creating the store. Caption: “Reducers update state; listeners handle storage.” Do not show the reducer performing storage writes or imply delayed saving cannot lose a last unsaved edit.
11. The board becomes shared: bring in RTK Query
Now someone asks, “Can I see the same tasks on my phone?” Browser storage cannot answer that. We need a server to own the shared list.
This changes the kind of problem we're solving. We need requests, loading feedback, errors, cache sharing, and refreshes after edits. RTK Query is Redux Toolkit's purpose-built fetching and caching system.
We will keep server todos in its cache. We will not copy that same response into the local todo slice.
The next section is a second working stage. The first todo app already runs without it. For this shared version, replace store.js and App.jsx as shown, then add the API and board files. The earlier local slice and persistence module are no longer registered in this version.
Give the shared version a small local server
This demo server keeps tasks in memory while it runs. Restarting it resets the list. It gives us a concrete API to learn against.
File at the project root: server.mjs
import http from 'node:http';
import { randomUUID } from 'node:crypto';
let todos = [];
function send(response, status, data) {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(data));
}
async function readJson(request) {
request.setEncoding('utf8');
let text = '';
for await (const chunk of request) text += chunk;
return JSON.parse(text || '{}');
}
export const server = http.createServer(async (request, response) => {
const path = new URL(request.url, 'http://localhost').pathname;
try {
if (path === '/api/todos' && request.method === 'GET') {
return send(response, 200, todos);
}
if (path === '/api/todos' && request.method === 'POST') {
const body = await readJson(request);
if (typeof body.title !== 'string' || !body.title.trim()) {
return send(response, 400, { message: 'A title is required' });
}
const todo = { id: randomUUID(), title: body.title.trim(), completed: false };
todos = [...todos, todo];
return send(response, 201, todo);
}
const match = path.match(/^\/api\/todos\/([^/]+)$/);
if (match) {
const id = decodeURIComponent(match[1]);
const todo = todos.find((item) => item.id === id);
if (!todo) return send(response, 404, { message: 'Task not found' });
if (request.method === 'PATCH') {
const body = await readJson(request);
if (typeof body.completed !== 'boolean') {
return send(response, 400, { message: 'completed must be a boolean' });
}
const updated = { ...todo, completed: body.completed };
todos = todos.map((item) => item.id === id ? updated : item);
return send(response, 200, updated);
}
if (request.method === 'DELETE') {
todos = todos.filter((item) => item.id !== id);
return send(response, 200, { id });
}
}
return send(response, 404, { message: 'Route not found' });
} catch {
return send(response, 400, { message: 'Invalid request' });
}
});
server.listen(3001, '127.0.0.1', () => {
console.log('Todo API running at http://127.0.0.1:3001');
});
Keep npm run dev in one terminal. Start the server in another:
node server.mjs
Replace the project-root vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://127.0.0.1:3001'
}
}
});
Restart the Vite dev server after this change. During development, the proxy forwards browser requests for /api to our Node server. A deployed app needs its own corresponding backend/routing setup.
Describe the endpoints instead of managing request state by hand
File: src/services/todoApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const todoApi = createApi({
reducerPath: 'todoApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query({
query: () => 'todos',
providesTags: [{ type: 'Todo', id: 'LIST' }]
}),
addTodo: builder.mutation({
query: (title) => ({
url: 'todos',
method: 'POST',
body: { title }
}),
invalidatesTags: (_result, error) =>
error ? [] : [{ type: 'Todo', id: 'LIST' }]
}),
updateTodo: builder.mutation({
query: ({ id, completed }) => ({
url: `todos/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { completed }
}),
invalidatesTags: (_result, error) =>
error ? [] : [{ type: 'Todo', id: 'LIST' }]
}),
deleteTodo: builder.mutation({
query: (id) => ({
url: `todos/${encodeURIComponent(id)}`,
method: 'DELETE'
}),
invalidatesTags: (_result, error) =>
error ? [] : [{ type: 'Todo', id: 'LIST' }]
})
})
});
export const {
useGetTodosQuery,
useAddTodoMutation,
useUpdateTodoMutation,
useDeleteTodoMutation
} = todoApi;
createApi creates a fetching system from those endpoint definitions. builder.query describes a read; builder.mutation describes an operation we explicitly trigger.
fetchBaseQuery wraps fetch with common JSON request/response handling. Its default success check accepts HTTP 200–299. Our backend deliberately uses failure statuses for invalid operations.
The /query/react import gives us generated React Hooks. getTodos becomes useGetTodosQuery, and addTodo becomes useAddTodoMutation. We also get an API reducer and middleware.
The tag named Todo/LIST connects the list query to mutations affecting that list. It is a cache label, not a task record.
Register the cache and its middleware
Replace src/store.js for the shared version:
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { todoApi } from './services/todoApi';
export const store = configureStore({
reducer: {
[todoApi.reducerPath]: todoApi.reducer
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(todoApi.middleware)
});
setupListeners(store.dispatch);
The API reducer stores cache state. Its middleware coordinates requests and cache behavior. getDefaultMiddleware() preserves Toolkit's defaults. setupListeners enables the browser-event handling needed if we later choose focus/reconnect refetching.
Read the list and send edits from the UI
File: src/SharedTodoBoard.jsx
import { useState } from 'react';
import {
useGetTodosQuery,
useAddTodoMutation,
useUpdateTodoMutation,
useDeleteTodoMutation
} from './services/todoApi';
function AddServerTodoForm() {
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [addTodo, { isLoading }] = useAddTodoMutation();
async function handleSubmit(event) {
event.preventDefault();
if (!title.trim() || isLoading) return;
setMessage('');
try {
await addTodo(title.trim()).unwrap();
setTitle('');
} catch {
setMessage('Could not add this task. Your draft is still here.');
}
}
return (
<form
<label>
Your next task
<input
value={title} => setTitle(event.target.value)}
disabled={isLoading}
/>
</label>
<button type="submit" disabled={isLoading || !title.trim()}>
{isLoading ? 'Adding…' : 'Add task'}
</button>
{message && <p role="alert">{message}</p>}
</form>
);
}
function ServerTodoRow({ todo }) {
const [message, setMessage] = useState('');
const [updateTodo, { isLoading: isUpdating }] = useUpdateTodoMutation();
const [deleteTodo, { isLoading: isDeleting }] = useDeleteTodoMutation();
const busy = isUpdating || isDeleting;
async function run(operation) {
setMessage('');
try {
await operation.unwrap();
} catch {
setMessage('Could not save this change. Please try again.');
}
}
return (
<li>
<label>
<input
type="checkbox"
checked={todo.completed}
disabled={busy} => run(updateTodo({
id: todo.id,
completed: !todo.completed
}))}
/>
{todo.completed ? <s>{todo.title}</s> : todo.title}
</label>
<button type="button" disabled={busy} => run(deleteTodo(todo.id))}>
Remove {todo.title}
</button>
{message && <p role="alert">{message}</p>}
</li>
);
}
export default function SharedTodoBoard() {
const [filter, setFilter] = useState('all');
const { data, isLoading, isFetching, error, refetch } = useGetTodosQuery();
const todos = data ?? [];
const remaining = todos.filter((todo) => !todo.completed).length;
const visibleTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
return (
<main>
<h1>Our next small wins</h1>
<AddServerTodoForm />
{isLoading ? <p role="status">Loading tasks…</p> : (
<p>{remaining} {remaining === 1 ? 'task' : 'tasks'} left · {todos.length} total</p>
)}
{error && <p role="alert">Could not load the latest tasks. Try refreshing.</p>}
{isFetching && !isLoading && <p role="status">Refreshing tasks…</p>}
<button type="button" disabled={isFetching} => refetch()}>
Refresh
</button>
<div aria-label="Filter tasks">
{['all', 'active', 'completed'].map((value) => (
<button
key={value}
type="button"
aria-pressed={filter === value} => setFilter(value)}
>
{value[0].toUpperCase() + value.slice(1)}
</button>
))}
</div>
{data && visibleTodos.length === 0 && <p>No tasks in this view.</p>}
<ul>
{visibleTodos.map((todo) => <ServerTodoRow key={todo.id} todo={todo} />)}
</ul>
</main>
);
}
Replace src/App.jsx with:
export { default } from './SharedTodoBoard';
Keep the same main.jsx and Provider. Open two browser tabs. Add a task in one, then refresh the other: both read the shared server list.
The server is shared, but the two tabs do not share one Redux store. Each has its own cache. Refetching policies determine when a tab learns about edits elsewhere.
Read the Hook results like a conversation with the server
data is the available query result. isLoading describes the initial load without data. isFetching describes an in-flight request, including a refresh while data already exists. That is why our board keeps existing rows visible during refreshes.
error lets the UI report a failed fetch, and refetch lets the user retry. A mutation Hook gives a trigger plus its request state. .unwrap() returns the successful response or throws a failure so our handler's try/catch can respond.
Watch one mutation complete
When we add a task, the server creates it and returns success. The mutation invalidates the list tag. An actively subscribed query providing that tag refetches, and the new list arrives in the cache.
We did not manually append the returned task to a second Redux array. The API cache remains the owner of the server list.
Insert image here — The shared board's new flow
Image prompt: Create a diagram titled “Add a task to the shared board.” Show a form triggering addTodo with a title, a POST request reaching the demo server, and the server returning the new task. Show successful mutation invalidating Todo/LIST, an actively subscribed getTodos query refetching, and the query cache receiving the new list. End with the board displaying that list. Add a separate note: “Another browser tab has its own store and must refetch to learn this change.” Do not depict tag invalidation as a websocket broadcast or an optimistic append.
12. Make the shared board feel quicker
The shared version is correct, but completing a task waits for the request and refresh. On a slow connection, the checkbox can feel unresponsive.
This is where optimistic updates help: update the existing cache immediately, then reconcile with the server.
Inside the API's updateTodo endpoint, add this field beside query and invalidatesTags:
async onQueryStarted({ id, completed }, { dispatch, queryFulfilled }) {
const patch = dispatch(
todoApi.util.updateQueryData('getTodos', undefined, (draft) => {
const todo = draft.find((item) => item.id === id);
if (todo) todo.completed = completed;
})
);
try {
await queryFulfilled;
} catch {
patch.undo();
}
}
updateQueryData edits an existing cache entry. The endpoint name and arguments must match that entry: ours is getTodos with undefined, because its Hook has no argument.
The patch changes what the board sees immediately. If the request fails, undo() rolls back that patch. Our successful-mutation tag invalidation still refetches, so the server's accepted result replaces the provisional display.
Optimistic updating changes perceived responsiveness; it does not make the network request faster or make a failed operation succeed. Keep failure feedback visible.
For overlapping mutations, rollback patches can interact. A safer recovery strategy can be invalidating affected tags and refetching authoritative data. Choose the recovery policy based on the workflow, especially when edits or deletions can overlap.
Insert image here — Immediate feedback with a failure path
Image prompt: Create a two-branch timeline for completing “Learn Redux.” Both branches begin with a click, an optimistic cache patch setting completed to true, and a request in flight. Success branch: server accepts the edit, list invalidation/refetch reconciles the cache. Failure branch: the isolated update's patch is undone, checkbox returns to false, and a visible error invites retry. Caption: “Show a provisional result, then reconcile.” Add a note: “Overlapping updates may require invalidation/refetch rather than naive rollback.” Do not imply optimistic data is already saved on the server.
Avoid duplicate fetching within one app
Suppose a sidebar and the main board both call useGetTodosQuery(). With the same endpoint and serialized arguments, RTK Query coordinates them through the same cache entry. You don't need separate fetch logic in each component.
After the last subscriber leaves, unused data is retained for 60 seconds by default. That timer controls unused-data removal. It does not refresh an active query every minute.
Different arguments produce different cache entries. RTK Query does not automatically merge every appearance of the same task across separate queries into one globally normalized record.
Subscribe to a smaller result
A sidebar that only needs one task's title can use selectFromResult. Here's a complete optional component for the shared version:
File: src/TodoTitle.jsx
import { useGetTodosQuery } from './services/todoApi';
export default function TodoTitle({ id }) {
const { todo } = useGetTodosQuery(undefined, {
selectFromResult: ({ data }) => ({
todo: data?.find((item) => item.id === id)
})
});
return <span>{todo?.title ?? 'Task unavailable'}</span>;
}
RTK Query shallowly compares the selected result's fields. If the selected task reference stays the same, changes to unrelated result fields do not force an update through this selection.
Return stable selected values. Building a new mapped array or nested object inside selectFromResult can defeat the benefit. This improves the subscription boundary; it does not eliminate all React rendering causes.
Decide how the board discovers outside edits
For a team board, you might replace its query call with:
const { data, isLoading, isFetching, error, refetch } = useGetTodosQuery(
undefined,
{
pollingInterval: 30000,
skipPollingIfUnfocused: true,
refetchOnFocus: true,
refetchOnReconnect: true
}
);
The example polls every 30 seconds and refreshes after focus or reconnect events. We already installed setupListeners for those browser events.
That is one freshness policy, not a setting to paste into every screen. More frequent requests cost network and backend work. A quiet reference page may need little refreshing; a live operations board may need a different approach, including streaming updates.
13. Add a details screen without building a second API system
A task details screen should not download the whole list just to read one task. Our server can support a specific record read.
Inside server.mjs, immediately after finding the matching task and checking it exists, add:
if (request.method === 'GET') return send(response, 200, todo);
Now GET /api/todos/:id returns that task.
For a growing app, RTK Query can inject endpoints into the same API service. The API's existing reducer and middleware continue handling the new endpoints.
File: src/services/todoDetailsApi.js
import { todoApi } from './todoApi';
const detailsApi = todoApi.injectEndpoints({
endpoints: (builder) => ({
getTodo: builder.query({
query: (id) => `todos/${encodeURIComponent(id)}`,
providesTags: (_result, _error, id) => [{ type: 'Todo', id }]
})
})
});
export const { useGetTodoQuery } = detailsApi;
The details query provides a tag for that task ID. To make edits refresh both affected views, refine the original API's list query and mutations.
Replace getTodos.providesTags with:
providesTags: (result) => [
{ type: 'Todo', id: 'LIST' },
...(result ?? []).map((todo) => ({ type: 'Todo', id: todo.id }))
]
Replace updateTodo.invalidatesTags with:
invalidatesTags: (_result, error, { id }) =>
error ? [] : [{ type: 'Todo', id }]
Replace deleteTodo.invalidatesTags with:
invalidatesTags: (_result, error, id) =>
error ? [] : [{ type: 'Todo', id }, { type: 'Todo', id: 'LIST' }]
Keep addTodo invalidating the list tag. Our UUID task IDs do not use the reserved 'LIST' label.
Now a task edit invalidates the tag supplied by its details query and by lists containing that task. A deletion also invalidates list membership. Actively subscribed affected entries refetch; unused invalidated entries can be removed.
This is targeted invalidation, not automatic entity normalization. The list and details are still separate cache entries.
Wait until a task has been selected
File: src/TodoDetails.jsx
import { useGetTodoQuery } from './services/todoDetailsApi';
export default function TodoDetails({ id }) {
const { currentData, isFetching, error } = useGetTodoQuery(id, {
skip: !id
});
if (!id) return <p>Select a task.</p>;
if (error) return <p role="alert">This task could not be loaded.</p>;
if (!currentData) return <p role="status">Loading task…</p>;
return (
<section>
<h2>{currentData.title}</h2>
<p>{currentData.completed ? 'Completed' : 'Still to do'}</p>
{isFetching && <p role="status">Refreshing task…</p>}
</section>
);
}
skip keeps the query from running before we have an ID. currentData represents data for the current argument; this avoids presenting the previously selected task as the new task while a new ID loads. A TypeScript version can use skipToken when that makes a required argument easier to type safely.
Separate endpoint files can also support code splitting. To reduce the initial bundle, load a feature module lazily when needed. Merely moving code into a different file does not make the browser delay loading it.
When the task list becomes a long feed
For “Load more” or infinite scrolling, RTK Query has builder.infiniteQuery. It keeps related pages and page parameters together in one cache entry. You define the first page parameter and how to find the next one; return undefined when there is no next page. maxPages can limit retained pages.
That needs a backend pagination contract. Our demo server currently returns the whole list, so an infinite-query endpoint would require extending it first. Pagination limits how much data is fetched; virtualization separately limits how much UI is mounted.
When polling is too slow for collaboration
A streaming endpoint can use onCacheEntryAdded to manage a websocket lifecycle, update cached data when messages arrive, and close the connection when that cache entry is removed. The backend still needs to send those messages. Tag invalidation alone is not a real-time subscription to server changes.
14. One event, several features
The app has grown again: todos, workspace preferences, and an activity panel. Leaving a workspace should reset feature-specific state consistently.
An event can have more than one interested reducer. It isn't privately addressed to one slice.
Independent event definition: src/workspaceEvents.js
import { createAction } from '@reduxjs/toolkit';
export const workspaceLeft = createAction('workspace/left');
In the original local todo slice, import that event and add this extraReducers field beside reducers:
extraReducers: (builder) => {
builder.addCase(workspaceLeft, () => ({ items: [], filter: 'all' }));
}
Other slices can respond to workspaceLeft too. extraReducers handles actions defined elsewhere without generating additional action creators. Its handlers are also Immer-backed.
For the shared server version, decide whether leaving a workspace should reset the API cache with todoApi.util.resetApiState(). Clearing client state or cache does not delete server tasks or end a server session by itself.
This pattern is useful for sign-out, workspace switching, order completion, and other events that have consequences across several features.
If a larger app lazily loads whole features, combineSlices supports injecting slice reducers as those features arrive. That is different from injecting API endpoints: one expands client-state reducer composition, the other expands an API service. A small fixed app can keep the simple reducer map we started with.
15. Performance in a real application: choose the right improvement
Our todo story introduced several optimizations, but they solve different problems.
Situation | A useful approach | What it improves |
|---|---|---|
Many components need the same shared client rules | A well-scoped Redux slice | Consistent ownership and update logic |
An unrelated update repeats filtering |
| Reuses derived work when inputs stay unchanged |
One expensive row changes among many | Stable props and | Allows unrelated rendering work to be reused |
Frequent lookups and edits in a client collection |
| Organizes records and simplifies ID-based updates |
Several screens request the same server result | RTK Query cache sharing | Coordinates requests for the same cache key |
A mutation changes one server record | Targeted tags | Limits cache invalidation to related entries |
Waiting for a save feels slow | Optimistic updates with recovery | Gives earlier feedback |
One widget needs a small query result |
| Narrows its subscription result |
Rapid edits trigger repeated external work | Listener debouncing/cancellation | Avoids some redundant work |
A screen renders thousands of DOM rows | Pagination or virtualization | Reduces mounted/rendered UI work |
An infrequently used feature adds substantial code | Lazy loading and endpoint injection | Can reduce initial code loading |
In an e-commerce app
A cart slice owns quantities and removal rules. Product data belongs in a server cache. A subtotal selector derives the displayed amount. Memoized derived results can help with repeated filtering, while a large catalogue also needs sensible pagination or virtualization.
RTK Query can let a product page and another consumer share a matching query entry. Checkout still needs server-side price and inventory validation; a Redux subtotal is a display calculation.
In an admin dashboard
Filters, selected rows, and draft interactions may be client state; orders and inventory come from the server. Updating an order can invalidate its details and affected lists. A small summary widget can select only the result it displays.
If orders are paginated, invalidating only a visible record may not refresh counts or page membership correctly. Use list or aggregate tags where the operation affects membership, totals, or sorting. Specific tags are a model of dependencies, not a reason to skip necessary refetches.
In a document editor
Client-owned records can benefit from normalization. A listener can debounce autosave work. Related UI can select a specific document rather than the entire workspace. Conflict resolution between writers still requires its own design; storing data in Redux does not resolve concurrent edits automatically.
In a live operations board
Polling, focus/refetch policies, or streaming can keep data fresh. Pick the policy based on acceptable staleness and request cost. Memoization cannot make an incoming stream stop changing its inputs.
The practical sequence is: reproduce the slow interaction, measure where time is spent, then choose a tool that addresses that cost. React rendering, browser layout/paint, and network waiting are different bottlenecks.
Insert image here — Match a tool to the observed problem
Image prompt: Create a compact decision diagram titled “What is making this interaction slow?” Branch into “Repeated calculation,” “Many UI rows,” “Duplicate or unnecessary requests,” and “Waiting for a save.” Map them to “Memoized selectors,” “Pagination or virtualization,” “Cache sharing and targeted invalidation,” and “Optimistic feedback with recovery,” respectively. Add a nearby note: “Also inspect layout/paint and backend latency.” Caption: “Measure first; optimize the work that is actually expensive.” Do not show invented timings or imply Redux alone makes an app faster.
16. Add TypeScript after the flow clicks
You can keep the same architecture and add types. Infer state and dispatch types from the store, then create typed Hooks once:
// In store.ts, after creating and exporting store:
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// hooks.ts — React-Redux 9.1 or later
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
Use these helpers throughout a TypeScript app. Define todo and slice-state types, annotate payloads with PayloadAction, and give query endpoints their result/argument types.
TypeScript checks code expectations, not the actual JSON a server sends. Validate external data when needed; current RTK Query supports Standard Schema-compatible schema validation. Authentication headers, response transformations, and custom base queries can adapt the API layer to your backend.
17. What changes if this becomes a Next.js app?
The browser-only tutorial exports a module-level store. In Next.js App Router, use a store factory so server requests do not share mutable user state. A Client Component provider owns its stable store instance; Client Components read and dispatch through it. React Server Components should not read or write the Redux store.
Initialize server and client state consistently for hydration. A provider in a persistent layout can preserve state across navigation, so reset route-specific values deliberately.
This is a framework integration concern, not a change to what actions, reducers, or selectors mean.
18. Keep the app easy to trust
You can now build features, but a few habits protect the architecture as it grows.
Keep a clear owner for each value. A form draft can stay local. A submitted local task can live in a slice. A server task list can live in the RTK Query cache. A remaining count can be derived. Avoid synchronized copies of the same facts.
Keep ordinary stored data serializable. Plain objects, arrays, strings, numbers, booleans, and null work well. Store a date string or timestamp instead of a Date instance. Promises, DOM elements, functions, and class instances do not belong in normal stored state.
Use Redux DevTools to investigate a transition. For an unexpected checkbox change, inspect the dispatched action, its task ID, and the resulting state. Time-travel inspection does not reverse server writes or storage side effects.
Test the behavior you care about. Add a task, complete it, filter it, and remove it. Verify previous state snapshots remain unchanged. In the shared version, test successful requests, failed requests, retry, cache invalidation, and optimistic recovery. Integration tests should use a real store rather than mocking Redux Hooks into a different system.
Keep reducer rules and external work separate. The reducer answers “What is the next state?” The API layer or listener answers “What work must happen outside state calculation?”
19. Close the loop: build one more feature yourself
Add a Complete all button to the local board.
First decide the event. Then add its rule to the slice. Export the generated action creator and dispatch it from a button. The remaining count and filtered view should react without separate counter updates.
That exercise uses the same flow you followed when adding “Learn Redux.” The feature is new; the process is familiar.
If you can explain why typing stays local, why submitting dispatches an event, why the reducer owns completion rules, why the summary is derived, and why server data later moves into an API cache, you understand the structure well enough to begin your own Redux feature.