Explorer
React

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:

JAVASCRIPT
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

src/features/todos/todosSlice.js

Remember tasks and define their update rules

src/store.js

Create the shared Redux store

src/App.jsx

Display the board and report user interactions

src/main.jsx

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:

JAVASCRIPT
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:

JAVASCRIPT
const exampleTodoState = {
  items: [],
  filter: 'all'
};

Here is the file that owns those values.

File: src/features/todos/todosSlice.js

JAVASCRIPT
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:

  • todoAdded puts a new, incomplete task into the array.

  • todoToggled finds one task by ID and flips its completion value.

  • todoRemoved removes the matching task; an unknown ID leaves state alone.

  • filterChanged remembers which view the user selected.

  • completedCleared removes 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:

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
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.” Show dispatch(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

todoAdded, which builds that event object

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

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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

JAVASCRIPT
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. Show ids: ["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

JAVASCRIPT
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:

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
node server.mjs

Replace the project-root vite.config.js:

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
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:

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
providesTags: (result) => [
  { type: 'Todo', id: 'LIST' },
  ...(result ?? []).map((todo) => ({ type: 'Todo', id: todo.id }))
]

Replace updateTodo.invalidatesTags with:

JAVASCRIPT
invalidatesTags: (_result, error, { id }) =>
  error ? [] : [{ type: 'Todo', id }]

Replace deleteTodo.invalidatesTags with:

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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:

JAVASCRIPT
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

createSelector

Reuses derived work when inputs stay unchanged

One expensive row changes among many

Stable props and memo, or per-record selection

Allows unrelated rendering work to be reused

Frequent lookups and edits in a client collection

createEntityAdapter

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

selectFromResult

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:

JAVASCRIPT
// In store.ts, after creating and exporting store:
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
JAVASCRIPT
// 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.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.