Build a Todo App with Redux Toolkit

Build a React 19 todo app with centralized state management using Redux Toolkit.

Introduction

30 Second Summary

Think about every app on your phone that manages a list of items. Shopping carts, task managers, and notification feeds all need the same thing: a reliable way to track what gets added, completed, and removed.

In this project, you will build a todo app with React 19 and Redux Toolkit. You'll manage all your app's state in one centralized store, learning the most popular state management pattern in the React ecosystem.

What You'll Build

A fully functional todo application where you can add new todos, mark them as complete, delete them, and filter the list by status.

By the end of this project, you'll have:

  • A Vite-scaffolded React 19 project with Redux Toolkit installed and configured.
  • A Redux store with two slices managing todo items and filter state through actions and reducers.
  • Five React components that read from and write to the store using useSelector and useDispatch.
  • A secret mission to add localStorage persistence so your todos survive a page refresh.

Are there any prerequisites?

This project assumes basic JavaScript knowledge including variables, functions, arrays, and objects. No prior React or Redux experience is required.

Scaffold and Install

Every React app starts with a scaffold. The scaffold generates your folder structure, dev server configuration, and build tooling so you can focus on writing code.

You'll use Vite to create your React project. You'll also install Redux Toolkit and React-Redux for state management.

In this step, get ready to:
  • Scaffold a React 19 project with Vite.
  • Install Redux Toolkit and React-Redux.
  • Start the dev server and confirm the welcome page loads.
Scaffold a React project with Vite
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new React project by running this command:
npm create vite@latest todo-app -- --template react
  • You should see output confirming the project was scaffolded in a new todo-app folder.

Seeing 'command not found'?

  • Make sure Node.js is installed by running node --version in your terminal.
  • If nothing shows up, download and install Node.js from nodejs.org before continuing.
  • Help me troubleshoot my npm installation.
  • Navigate into the project folder and install the base dependencies by running these commands:
cd todo-app
npm install
  • You should see added followed by a package count in the terminal output, confirming all base dependencies are installed.

Why Vite instead of Create React App?

Create React App was deprecated in February 2025. Vite is now the standard scaffolding tool for React projects.

Vite uses native ES modules for near-instant dev server startup and fast hot module replacement. Changes you make to your code show up in the browser almost immediately.

Install Redux Toolkit and React-Redux

Your project needs two additional libraries for state management. Redux Toolkit provides the store and slice APIs, and React-Redux connects the store to your React components.

  • Install both libraries by running this command:
npm install @reduxjs/toolkit react-redux
  • You should see added followed by a package count in the terminal output.

Install failing?

  • Make sure you're inside the todo-app folder. Run pwd to check your current directory.
  • Check your internet connection and try running the command again.
  • Help me fix my npm install error.
Open the project and start the dev server
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the todo-app folder on your Desktop, then click Open.

You should see the project's file tree in the Explorer sidebar on the left, including folders like src/ and node_modules/.

  • Click Terminal in the top menu bar.
  • Click New Terminal.
  • Start the development server by running this command:
npm run dev
  • You should see output in the terminal showing the dev server is running at http://localhost:5173.
  • Open http://localhost:5173 in your browser.

You should see the default Vite + React welcome page with a spinning logo and a counter button.

Don't see the welcome page?

  • Make sure the terminal still shows the dev server running. If you closed it, run npm run dev again.
  • Check that you're visiting http://localhost:5173 exactly, not https.
  • If port 5173 is already in use, Vite picks the next available port. Check the terminal output for the actual URL.
  • Help me get my Vite dev server running.

Your React project is up and running. Next up, you'll create the Redux store and connect it to your app.

Configure the Redux Store

Your project is scaffolded and the dev server is running. Now it's time to build the brain of your app.

In Redux, all application state lives in a single centralized store. Components read from this store and send updates to it instead of passing data through layers of props. In this step, you'll create that store and wire it up to React.

In this step, get ready to:
  • Create a Redux store file with configureStore.
  • Connect the store to your React app using Provider.
  • Verify the store is working with Redux DevTools.
Create the store file

Redux Toolkit provides a function called configureStore that sets up a Redux store with sensible defaults. It automatically enables Redux DevTools and includes common middleware so you don't have to configure them yourself.

  • In VS Code's Explorer sidebar, right-click on the src folder.
  • Click New Folder and name it app.
  • Right-click on the new app folder and click New File.
  • Name the file store.js.
  • Add the following code to store.js:
import { configureStore } from '@reduxjs/toolkit'

// Create and export the Redux store with an empty reducer object
// You'll add slices to this reducer in later steps
export default configureStore({
  reducer: {},
})

What does this code do?

  • configureStore creates a Redux store with built-in defaults like DevTools support and middleware.
  • The reducer object is where you'll register your state slices. It's empty because you haven't created any slices yet.
  • export default makes the store available for other files to import.
  • Save store.js.

You won't see a change yet. The store exists but isn't connected to your React app. You'll connect it next.

Seeing a red underline on the import?

If VS Code shows a warning on the @reduxjs/toolkit import, make sure you installed Redux Toolkit in Step 1. You can verify by checking that @reduxjs/toolkit appears in your package.json under dependencies.

Help me fix my Redux Toolkit import in store.js

Awesome, I've got everything!

Make sure you've saved store.js before moving on.

I'd like to double check the full code

import { configureStore } from '@reduxjs/toolkit'

// Create and export the Redux store with an empty reducer object
// You'll add slices to this reducer in later steps
export default configureStore({
  reducer: {},
})
Connect the store to your React app

A Redux store on its own can't talk to your React components. The Provider component from react-redux bridges that gap. It wraps your entire app and makes the store available to every component through React's context system.

  • Open src/main.jsx in VS Code.
  • Select all the existing code and replace it with the following:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './app/store'
import App from './App'

// Wrap App with Provider so all components can access the Redux store
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)

What does this code do?

  • Provider wraps your entire app and passes the store down through React context. Every component inside Provider can now read from and write to the store.
  • store is imported from the file you just created. This is the same store instance every component will share.
  • React.StrictMode is a development helper that highlights potential problems in your app. It doesn't affect production builds.

Why did we replace the whole file?

The default Vite template includes imports and styles you don't need for this project. Replacing the entire file gives you a clean starting point with only the Redux and React setup you need.

  • Save main.jsx.
  • Check your browser at http://localhost:5173. The Vite welcome page should still load without any console errors.

Seeing a blank page or console errors?

  • Make sure the import path './app/store' matches the folder and file you created. The file should be at src/app/store.js.
  • Check that you didn't accidentally delete the App import line. Without it, React has nothing to render.
  • Open the browser console with F12 and check the Console tab for specific error messages.

Help me fix my main.jsx Provider setup

Awesome, I've got everything!

Make sure you've saved main.jsx and confirmed the page loads without errors.

I'd like to double check the full code

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './app/store'
import App from './App'

// Wrap App with Provider so all components can access the Redux store
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)
Verify the store with Redux DevTools

Redux DevTools is a browser extension that lets you inspect your store's state and see every action that gets dispatched. Since configureStore enables DevTools automatically, you just need to install the extension to start using it.

  • Open your browser's developer tools by pressing F12 or Cmd+Option+I (macOS) / Ctrl+Shift+I (Windows).
  • Look for a Redux tab along the top of the developer tools panel.

I see a Redux tab

The extension is already installed. You're all set to inspect your store.

I don't see a Redux tab

You need to install the Redux DevTools browser extension first.

  • For Chrome: search Redux DevTools in the Chrome Web Store and click Add to Chrome.
  • For Firefox: search Redux DevTools in Firefox Add-ons and click Add to Firefox.
  • After installing, refresh http://localhost:5173.
  • Reopen developer tools with F12. You should now see the Redux tab.
  • Click on the Redux tab in developer tools.
  • Look at the action list on the left side. You should see a @@INIT action.

The @@INIT action is dispatched automatically when the store is created. Seeing it confirms that your Redux store is connected and ready to go.

Why use Redux DevTools?

Redux DevTools lets you see every action dispatched to your store and inspect the state after each action. As you add todos in later steps, you'll be able to watch the state change in real time. This is one of the biggest advantages of using Redux for state management.

Redux tab shows 'No store found'?

  • Make sure you're looking at the tab for http://localhost:5173, not a different page.
  • Refresh the page after installing the extension. DevTools can't detect stores that were created before the extension loaded.
  • Double-check that main.jsx imports the store and wraps App with Provider. Without Provider, the store exists but isn't connected to React.

Help me get Redux DevTools working

Your Redux store is live and connected to React. Right now the store is empty, but in the next step you'll fill it with a todos slice that manages your entire todo list.

Build the Todos Slice

Your Redux store is connected to React and visible in DevTools. Right now, though, it has nothing to manage.

A slice bundles together the state, reducer functions, and action creators for one feature of your app. In this step, you'll create a todos slice that knows how to add, toggle, and delete todo items.

In this step, get ready to:
  • Create a todos slice with three reducer functions.
  • Export the auto-generated action creators and the slice reducer.
  • Connect the todos reducer to the Redux store and verify it in DevTools.
Create the todos slice file

Redux Toolkit's createSlice function lets you define your state shape, reducer logic, and action creators all in one place. Each reducer function describes how the state should change when a specific action happens.

  • Create a new folder path src/features/todos/ inside your project by running this command in the terminal:
mkdir -p src/features/todos
  • Confirm the folder was created by running:
ls src/features/todos

The folder should exist with no files listed yet. That's expected.

  • In VS Code, click File in the top menu bar.
  • Click New File.
  • Type src/features/todos/todosSlice.js as the file name and press Enter.
  • Paste the following code into todosSlice.js:
import { createSlice, nanoid } from '@reduxjs/toolkit'

// Define a slice that manages the todos array
const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // addTodo uses a prepare callback to build the payload with a unique ID
    addTodo: {
      reducer(state, action) {
        state.push(action.payload)
      },
      prepare(text) {
        return { payload: { id: nanoid(), text, completed: false } }
      },
    },
    // Find the matching todo by ID and flip its completed flag
    toggleTodo(state, action) {
      const todo = state.find((todo) => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    // Return a new array without the deleted todo
    deleteTodo(state, action) {
      return state.filter((todo) => todo.id !== action.payload)
    },
  },
})

What does this code do?

  • createSlice takes a name, initial state, and an object of reducer functions. It auto-generates action creators that match each reducer.
  • addTodo uses a prepare callback to customize the action payload before it reaches the reducer. The prepare function generates a unique ID with nanoid (bundled with Redux Toolkit) and bundles it with the text and a completed: false flag.
  • toggleTodo finds the todo by ID and flips its completed value. This looks like a direct mutation, but Immer (built into Redux Toolkit) converts it to a safe immutable update behind the scenes.
  • deleteTodo returns a new array that excludes the todo with the matching ID. When you return a new value instead of mutating, Immer replaces the entire state with your return value.

Why can I mutate state inside a Redux reducer?

Normally, Redux requires you to return a brand new state object from every reducer. Redux Toolkit includes a library called Immer that lets you write code that looks like mutations (state.push(), todo.completed = !todo.completed). Immer intercepts those changes and produces a new immutable state object for you.

You won't see a change in the browser yet. The next substep adds the exports that make this slice usable.

Export the action creators and reducer

When createSlice runs, it auto-generates an action creator function for each reducer you defined. You need to export those action creators so your React components can dispatch them. You also need to export the slice's combined reducer so the store can use it.

  • Add the following two lines at the bottom of todosSlice.js, after the closing ) of createSlice:
// Named export: individual action creators for components to dispatch
export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions
// Default export: the combined reducer for the store
export default todosSlice.reducer

What are action creators?

An action creator is a function that returns an action object. For example, calling addTodo('Buy groceries') returns something like { type: 'todos/addTodo', payload: { id: 'abc123', text: 'Buy groceries', completed: false } }. You never have to build these objects by hand.

  • Save todosSlice.js.

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

Here is the complete todosSlice.js file. Make sure yours matches:

import { createSlice, nanoid } from '@reduxjs/toolkit'

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer(state, action) {
        state.push(action.payload)
      },
      prepare(text) {
        return { payload: { id: nanoid(), text, completed: false } }
      },
    },
    toggleTodo(state, action) {
      const todo = state.find((todo) => todo.id === action.payload)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    deleteTodo(state, action) {
      return state.filter((todo) => todo.id !== action.payload)
    },
  },
})

export const { addTodo, toggleTodo, deleteTodo } = todosSlice.actions
export default todosSlice.reducer
Register the todos reducer in the store

The slice is defined, but the store doesn't know about it yet. You need to import the todos reducer and add it to the store's reducer object so Redux can manage the todos state.

  • In store.js, find the existing import line:
import { configureStore } from '@reduxjs/toolkit'
  • Replace the entire contents of store.js with the following updated code:
import { configureStore } from '@reduxjs/toolkit'
// Import the reducer from the todos slice
import todosReducer from '../features/todos/todosSlice'

export default configureStore({
  reducer: {
    // The key 'todos' becomes the state path: state.todos
    todos: todosReducer,
  },
})

What does this change do?

  • todosReducer is the default export from your slice file. It's the function that knows how to handle addTodo, toggleTodo, and deleteTodo actions.
  • The key todos in the reducer object determines the state path. Your components will access this data as state.todos.
  • Save store.js.
  • Open your browser at http://localhost:5173.
  • Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Redux tab in DevTools.
  • Click the State tab in the right panel. You should see todos: [] listed in the store state.

Don't see todos in the state?

  • Make sure the import path in store.js is exactly '../features/todos/todosSlice'. A wrong path causes a silent import failure.
  • Check that todosSlice.js has export default todosSlice.reducer at the bottom. Without the default export, the store receives undefined instead of a reducer function.
  • Verify that both files are saved. Unsaved changes won't reach the dev server.

Help me debug why my Redux store doesn't show a todos state

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

Here is the complete store.js at this point:

import { configureStore } from '@reduxjs/toolkit'
import todosReducer from '../features/todos/todosSlice'

export default configureStore({
  reducer: {
    todos: todosReducer,
  },
})

Your store now has a fully functional todos slice. Next up, you'll build the React components that let users add, complete, and delete todos by dispatching the actions you just created.

Build the Todo Components

Your Redux store now has a todos slice with three reducers that know how to add, toggle, and delete todos.

But a store without a UI is like a brain without hands. In this step, you'll build three React components that connect to the store and let users interact with their todos.

In this step, get ready to:
  • Create the TodoInput component with a form that dispatches the addTodo action.
  • Create the TodoItem and TodoList components to display and interact with todos.
  • Wire everything together in App.jsx and add the project styles.
Create the TodoInput component

The TodoInput component is a form with a text field and an Add button. It uses React's useState hook to track what the user types, and Redux's useDispatch hook to send the typed text to the store as a new todo.

  • Create the components folder inside src/ by running this command in your terminal:
mkdir src/components
  • Create a new file called TodoInput.jsx inside the src/components/ folder and paste in the following code:
import { useState } from 'react'
import { useDispatch } from 'react-redux'
import { addTodo } from '../features/todos/todosSlice'

function TodoInput() {
  // Local state to track the input field value
  const [text, setText] = useState('')
  // Get the dispatch function to send actions to the Redux store
  const dispatch = useDispatch()

  const handleSubmit = (e) => {
    // Prevent the default form reload
    e.preventDefault()
    if (text.trim()) {
      // Send the trimmed text to the store as a new todo
      dispatch(addTodo(text.trim()))
      // Clear the input field after adding
      setText('')
    }
  }

  return (
    <form onSubmit={handleSubmit} className="todo-input">
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  )
}

export default TodoInput

What does this code do?

  • useState('') creates a piece of local state called text that tracks whatever the user types into the input field.
  • useDispatch() gives the component access to the Redux store's dispatch function, which is how you send actions to the store.
  • When the form is submitted, handleSubmit dispatches the addTodo action with the trimmed input text, then clears the field.
  • e.preventDefault() stops the browser from reloading the page when the form submits.

Why mix useState with Redux?

The text the user is currently typing only matters to this one input field. No other component needs it. Local useState is perfect for short-lived, component-specific state like this.

Redux is for state that multiple components share, like the list of todos. Keeping form input local and shared data in Redux is a common pattern in production apps.

  • Save TodoInput.jsx.

You won't see a change in the browser yet. The component needs to be rendered by App.jsx first, which you'll do in the last substep.

Create the TodoItem and TodoList components

TodoItem renders a single todo with its text, a click handler to toggle completion, and a delete button. TodoList pulls the full array of todos from the Redux store and renders one TodoItem for each.

  • Create a new file called TodoItem.jsx inside src/components/ and paste in the following code:
import { useDispatch } from 'react-redux'
import { toggleTodo, deleteTodo } from '../features/todos/todosSlice'

function TodoItem({ todo }) {
  const dispatch = useDispatch()

  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      {/* Clicking the text toggles the todo's completed status */}
      <span onClick={() => dispatch(toggleTodo(todo.id))}>
        {todo.text}
      </span>
      {/* Clicking Delete removes the todo from the store */}
      <button onClick={() => dispatch(deleteTodo(todo.id))}>Delete</button>
    </li>
  )
}

export default TodoItem

What does this code do?

  • TodoItem receives a single todo object as a prop. It reads todo.text to display and todo.id to identify which todo to toggle or delete.
  • The className uses a template literal to add the completed CSS class when todo.completed is true. This is how the strikethrough styling gets applied.
  • Clicking the span dispatches toggleTodo(todo.id), and clicking the button dispatches deleteTodo(todo.id).
  • Save TodoItem.jsx.

You won't see a change yet. TodoItem still needs TodoList to render it.

  • Create a new file called TodoList.jsx inside src/components/ and paste in the following code:
import { useSelector } from 'react-redux'
import TodoItem from './TodoItem'

function TodoList() {
  // Read the todos array from the Redux store
  const todos = useSelector((state) => state.todos)

  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <p className="empty-message">No todos to show</p>
      ) : (
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} />)
      )}
    </ul>
  )
}

export default TodoList

What does this code do?

  • useSelector((state) => state.todos) reaches into the Redux store and grabs the todos array. Whenever that array changes, React automatically re-renders this component.
  • If the array is empty, a No todos to show message appears. Otherwise, .map() creates one TodoItem for each todo.
  • The key={todo.id} prop helps React track which items changed so it only updates what's necessary.
  • Save TodoList.jsx.

You still won't see a change. The next substep wires these components into the app.

Seeing a red error in VS Code?

  • Check that all three files are inside src/components/, not directly in src/.
  • Make sure each file name ends in .jsx, not .js. Vite needs the .jsx extension for files that contain JSX syntax.
  • Verify the import paths in TodoList.jsx use './TodoItem' (same folder), not '../components/TodoItem'.

Help me figure out why my component files have errors.

Wire up the components and add styles

Your three components are ready, but nothing renders them yet. You need to update App.jsx to import and display them, replace the default Vite styles, and remove a conflicting CSS import.

  • Open src/App.jsx and replace its entire contents with the following code:
import TodoInput from './components/TodoInput'
import TodoList from './components/TodoList'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>Todo App</h1>
      {/* Input form for adding new todos */}
      <TodoInput />
      {/* List that reads todos from the Redux store */}
      <TodoList />
    </div>
  )
}

export default App

What does this code do?

App is the root component that composes your UI. It imports TodoInput and TodoList as child components and renders them inside a container div. This replaces the default Vite counter demo that was here before.

  • Save App.jsx.

The app renders now, but it looks rough because the default Vite styles are still active. Next, you'll replace them.

  • Open src/App.css and replace its entire contents with the following styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  color: #333;
}

.app {
  max-width: 500px;
  margin: 40px auto;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.app h1 {
  text-align: center;
  margin-bottom: 20px;
  color: #1a1a2e;
}

.todo-input {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.todo-input input {
  flex: 1;
  padding: 10px 12px;
  border: 2px solid #e0e0e0;
  border-radius: 6px;
  font-size: 16px;
  outline: none;
  transition: border-color 0.2s;
}

.todo-input input:focus {
  border-color: #4a90d9;
}

.todo-input button {
  padding: 10px 20px;
  background-color: #4a90d9;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.todo-input button:hover {
  background-color: #357abd;
}

.filter-buttons {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
  justify-content: center;
}

.filter-buttons button {
  padding: 6px 16px;
  border: 1px solid #e0e0e0;
  border-radius: 20px;
  background: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.filter-buttons button:hover {
  background-color: #f0f0f0;
}

.filter-buttons button.active {
  background-color: #4a90d9;
  color: white;
  border-color: #4a90d9;
}

.todo-list {
  list-style: none;
}

.todo-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px;
  border-bottom: 1px solid #f0f0f0;
}

.todo-item span {
  cursor: pointer;
  flex: 1;
}

.todo-item.completed span {
  text-decoration: line-through;
  color: #999;
}

.todo-item button {
  padding: 4px 12px;
  background-color: #ff6b6b;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  transition: background-color 0.2s;
}

.todo-item button:hover {
  background-color: #ee5a5a;
}

.empty-message {
  text-align: center;
  color: #999;
  padding: 20px;
  font-style: italic;
}

What do these styles do?

  • The global reset (* selector) removes default browser margins and padding so spacing is consistent.
  • .app centers a white card on a light gray background with a subtle shadow.
  • .todo-item.completed span adds a strikethrough and gray color to completed todos, giving clear visual feedback.
  • The .filter-buttons styles are included for the next step, when you add filtering.
  • Save App.css.

The Vite template also includes an index.css file with global styles that conflict with your new CSS. You need to remove its import from main.jsx.

  • Open src/main.jsx.
  • Find this line:
import './index.css'
  • Delete that entire line.
  • Save main.jsx.

Now test the app. Refresh your browser at http://localhost:5173 if it hasn't already refreshed automatically.

  • Type a todo into the input field and click Add. You should see it appear in the list below.
  • Click on the todo text. It should get a strikethrough, showing it's completed.
  • Click Delete next to a todo. It should disappear from the list.
  • Add a few more todos to confirm everything works consistently.

App not rendering or styles look wrong?

  • If you see a blank page, open the browser console (F12 then Console tab) and check for import errors. A typo in a file path like './component/TodoInput' instead of './components/TodoInput' is a common cause.
  • If the styles look off (purple background, centered text), make sure you deleted the import './index.css' line from main.jsx.
  • If clicking a todo doesn't show a strikethrough, confirm the className in TodoItem.jsx uses backtick template literals, not regular quotes.

Help me debug why my todo app isn't rendering correctly.

Awesome, I've got everything!

Make sure you've saved all your files. Your todo app should be fully functional with add, toggle, and delete.

I'd like to double check the full code

Here is the full code for every file you created or modified in this step.

import { useState } from 'react'
import { useDispatch } from 'react-redux'
import { addTodo } from '../features/todos/todosSlice'

function TodoInput() {
  const [text, setText] = useState('')
  const dispatch = useDispatch()

  const handleSubmit = (e) => {
    e.preventDefault()
    if (text.trim()) {
      dispatch(addTodo(text.trim()))
      setText('')
    }
  }

  return (
    <form onSubmit={handleSubmit} className="todo-input">
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  )
}

export default TodoInput
import { useDispatch } from 'react-redux'
import { toggleTodo, deleteTodo } from '../features/todos/todosSlice'

function TodoItem({ todo }) {
  const dispatch = useDispatch()

  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <span onClick={() => dispatch(toggleTodo(todo.id))}>
        {todo.text}
      </span>
      <button onClick={() => dispatch(deleteTodo(todo.id))}>Delete</button>
    </li>
  )
}

export default TodoItem
import { useSelector } from 'react-redux'
import TodoItem from './TodoItem'

function TodoList() {
  const todos = useSelector((state) => state.todos)

  return (
    <ul className="todo-list">
      {todos.length === 0 ? (
        <p className="empty-message">No todos to show</p>
      ) : (
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} />)
      )}
    </ul>
  )
}

export default TodoList
import TodoInput from './components/TodoInput'
import TodoList from './components/TodoList'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>Todo App</h1>
      <TodoInput />
      <TodoList />
    </div>
  )
}

export default App
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  color: #333;
}

.app {
  max-width: 500px;
  margin: 40px auto;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.app h1 {
  text-align: center;
  margin-bottom: 20px;
  color: #1a1a2e;
}

.todo-input {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.todo-input input {
  flex: 1;
  padding: 10px 12px;
  border: 2px solid #e0e0e0;
  border-radius: 6px;
  font-size: 16px;
  outline: none;
  transition: border-color 0.2s;
}

.todo-input input:focus {
  border-color: #4a90d9;
}

.todo-input button {
  padding: 10px 20px;
  background-color: #4a90d9;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.todo-input button:hover {
  background-color: #357abd;
}

.filter-buttons {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
  justify-content: center;
}

.filter-buttons button {
  padding: 6px 16px;
  border: 1px solid #e0e0e0;
  border-radius: 20px;
  background: white;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

.filter-buttons button:hover {
  background-color: #f0f0f0;
}

.filter-buttons button.active {
  background-color: #4a90d9;
  color: white;
  border-color: #4a90d9;
}

.todo-list {
  list-style: none;
}

.todo-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px;
  border-bottom: 1px solid #f0f0f0;
}

.todo-item span {
  cursor: pointer;
  flex: 1;
}

.todo-item.completed span {
  text-decoration: line-through;
  color: #999;
}

.todo-item button {
  padding: 4px 12px;
  background-color: #ff6b6b;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  transition: background-color 0.2s;
}

.todo-item button:hover {
  background-color: #ee5a5a;
}

.empty-message {
  text-align: center;
  color: #999;
  padding: 20px;
  font-style: italic;
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import store from './app/store'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)

Your todo app is fully functional. You can add todos, mark them complete, and delete them. Next up, you'll add filter buttons so users can view only active or completed todos.

Add Filtering

Your todo app can add, complete, and delete items. All of that state flows through a single Redux store. But right now, every todo shows up in the list no matter what. When a list grows, you need a way to focus on just the items that matter.

In this step, you will build a second Redux slice to manage a filter value. You will also create a FilterButtons component and update TodoList to only show the todos that match the active filter.

In this step, get ready to:
  • Create a filter slice and register it in the Redux store.
  • Build a FilterButtons component that lets users switch between All, Active, and Completed views.
  • Update TodoList to filter todos based on the active filter before rendering.
Create the filter slice and register it in the store

Your todos slice manages an array of todo objects. The filter slice is simpler. It holds a single string that represents which todos to show: 'all', 'active', or 'completed'.

  • Create a new folder at src/features/filter/.
  • Inside that folder, create a file called filterSlice.js and add the following code:
import { createSlice } from '@reduxjs/toolkit'

const filterSlice = createSlice({
  // Name used as the prefix for action types (e.g. 'filter/setFilter')
  name: 'filter',
  // Default filter shows all todos
  initialState: 'all',
  reducers: {
    // Replaces the current filter value with whatever is dispatched
    setFilter(state, action) {
      return action.payload
    },
  },
})

export const { setFilter } = filterSlice.actions
export default filterSlice.reducer

What does this code do?

  • createSlice creates a slice of state with the name 'filter' and a starting value of 'all'.
  • The setFilter reducer uses return action.payload to completely replace the state with the new filter value. This works because the state is a plain string, not an object.
  • The exported setFilter action creator is what components will dispatch to change the filter.

Why a separate slice instead of adding filter to the todos slice?

The filter controls which todos are visible. It does not describe a todo itself. Keeping it in its own slice follows the Redux Toolkit pattern of organizing state by feature. Each slice owns one piece of data and exposes actions for that data only.

  • Save the file.

The slice exists, but the store does not know about it yet. You need to register the filter reducer in store.js.

  • Open src/app/store.js and replace its contents with the following updated code:
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from '../features/todos/todosSlice'
import filterReducer from '../features/filter/filterSlice'

export default configureStore({
  reducer: {
    todos: todosReducer,
    filter: filterReducer,
  },
})

What changed?

You imported filterReducer and added it to the reducer object under the key filter. Now the store manages two slices of state: state.todos (an array) and state.filter (a string).

  • Save store.js.
  • Open Redux DevTools in your browser. You should see both todos and filter in the state tree, with filter set to "all".

Don't see filter in Redux DevTools?

  • Make sure you saved both filterSlice.js and store.js. The dev server only reloads when files are saved.
  • Check that the import path in store.js is exactly '../features/filter/filterSlice'. A wrong path causes a module-not-found error in the terminal.
  • help me debug why my filter slice is not showing up in Redux DevTools
Build the FilterButtons component

The filter slice holds the state, but there is no UI for changing it yet. You need a set of buttons that let users switch between All, Active, and Completed.

  • Create a new file at src/components/FilterButtons.jsx and add the following code:
import { useSelector, useDispatch } from 'react-redux'
import { setFilter } from '../features/filter/filterSlice'

function FilterButtons() {
  // Read the current filter value from the Redux store
  const currentFilter = useSelector((state) => state.filter)
  const dispatch = useDispatch()

  // The three filter options
  const filters = ['all', 'active', 'completed']

  return (
    <div className="filter-buttons">
      {filters.map((filter) => (
        <button
          key={filter}
          // Highlight the active filter with the 'active' CSS class
          className={currentFilter === filter ? 'active' : ''}
          onClick={() => dispatch(setFilter(filter))}
        >
          {/* Capitalize the first letter for display */}
          {filter.charAt(0).toUpperCase() + filter.slice(1)}
        </button>
      ))}
    </div>
  )
}

export default FilterButtons

What does this code do?

  • useSelector reads state.filter from the store so the component knows which filter is currently active.
  • The component maps over the three filter strings and renders a button for each one. Clicking a button dispatches setFilter with that filter value.
  • The className conditional adds the 'active' CSS class to whichever button matches the current filter. The styles you added in the previous step already handle the highlight color.
  • Save FilterButtons.jsx.

You will not see anything in the browser yet because FilterButtons has not been imported into App.jsx. You will wire it up in the next substep.

Wire up filtering in TodoList and App

The filter state exists in Redux and FilterButtons can change it. Now you need to make TodoList actually use the filter value to decide which todos to display.

  • Open src/components/TodoList.jsx and replace its entire contents with the following updated code:
import { useSelector } from 'react-redux'
import TodoItem from './TodoItem'

function TodoList() {
  const todos = useSelector((state) => state.todos)
  // Read the active filter from the store
  const filter = useSelector((state) => state.filter)

  // Show only the todos that match the active filter
  const filteredTodos = todos.filter((todo) => {
    if (filter === 'active') return !todo.completed
    if (filter === 'completed') return todo.completed
    return true
  })

  return (
    <ul className="todo-list">
      {filteredTodos.length === 0 ? (
        <p className="empty-message">No todos to show</p>
      ) : (
        filteredTodos.map((todo) => <TodoItem key={todo.id} todo={todo} />)
      )}
    </ul>
  )
}

export default TodoList

What changed from the previous version?

  • A second useSelector call reads state.filter from the store.
  • The new filteredTodos variable filters the full array based on the active filter. If the filter is 'active', it keeps only incomplete todos. If it is 'completed', it keeps only finished ones. Otherwise it returns everything.
  • The render logic now uses filteredTodos instead of todos for both the empty check and the map.
  • Save TodoList.jsx.

Now add the FilterButtons component to your app layout.

  • Open src/App.jsx.
  • Find this import line near the top of the file:
import TodoList from './components/TodoList'
  • Add this new import directly below it:
import FilterButtons from './components/FilterButtons'
  • Now find the <TodoInput /> line inside the return statement:
<TodoInput />
<TodoList />
  • Add <FilterButtons /> between them, so the three lines now look like this:
<TodoInput />
<FilterButtons />
<TodoList />
  • Save App.jsx and check your browser at http://localhost:5173.

You should see three rounded filter buttons (All, Active, Completed) between the input and the todo list. The All button appears highlighted in blue because the current filter is 'all'.

  • Add two or three todos using the input field.
  • Click on a todo's text to mark it as completed.
  • Click the Active filter button. Only the incomplete todos should remain visible.
  • Click the Completed filter button. Only the completed todos should appear.
  • Click All to see every todo again.

Filter buttons not showing or filtering not working?

  • If you see no buttons at all, check that the import FilterButtons from './components/FilterButtons' line exists in App.jsx and that <FilterButtons /> is included in the JSX.
  • If clicking the buttons does nothing, open Redux DevTools and check if filter/setFilter actions appear when you click. If they do, the issue is in TodoList.jsx. Make sure you are rendering filteredTodos and not todos.
  • help me debug my filter buttons in my Redux todo app

Awesome, I've got everything!

Make sure you have saved all your files before moving on.

I'd like to double check the full code

Here are all the files you created or updated in this step.

import { createSlice } from '@reduxjs/toolkit'

const filterSlice = createSlice({
  name: 'filter',
  initialState: 'all',
  reducers: {
    setFilter(state, action) {
      return action.payload
    },
  },
})

export const { setFilter } = filterSlice.actions
export default filterSlice.reducer
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from '../features/todos/todosSlice'
import filterReducer from '../features/filter/filterSlice'

export default configureStore({
  reducer: {
    todos: todosReducer,
    filter: filterReducer,
  },
})
import { useSelector, useDispatch } from 'react-redux'
import { setFilter } from '../features/filter/filterSlice'

function FilterButtons() {
  const currentFilter = useSelector((state) => state.filter)
  const dispatch = useDispatch()

  const filters = ['all', 'active', 'completed']

  return (
    <div className="filter-buttons">
      {filters.map((filter) => (
        <button
          key={filter}
          className={currentFilter === filter ? 'active' : ''}
          onClick={() => dispatch(setFilter(filter))}
        >
          {filter.charAt(0).toUpperCase() + filter.slice(1)}
        </button>
      ))}
    </div>
  )
}

export default FilterButtons
import { useSelector } from 'react-redux'
import TodoItem from './TodoItem'

function TodoList() {
  const todos = useSelector((state) => state.todos)
  const filter = useSelector((state) => state.filter)

  const filteredTodos = todos.filter((todo) => {
    if (filter === 'active') return !todo.completed
    if (filter === 'completed') return todo.completed
    return true
  })

  return (
    <ul className="todo-list">
      {filteredTodos.length === 0 ? (
        <p className="empty-message">No todos to show</p>
      ) : (
        filteredTodos.map((todo) => <TodoItem key={todo.id} todo={todo} />)
      )}
    </ul>
  )
}

export default TodoList
import TodoInput from './components/TodoInput'
import TodoList from './components/TodoList'
import FilterButtons from './components/FilterButtons'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>Todo App</h1>
      <TodoInput />
      <FilterButtons />
      <TodoList />
    </div>
  )
}

export default App

Secret mission

Add localStorage Persistence

Right now, refreshing the page wipes out all your todos. In this mission, you'll sync your Redux store with the browser's localStorage so your todos survive page refreshes and browser restarts.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Vite React project folder with all source files.
  • Node modules installed via npm install (inside the project folder).
  • Vite dev server running at http://localhost:5173.
  • Redux DevTools browser extension (if installed).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the app with the Secret Mission.

  • Your Vite dev server will keep running until you close the terminal or press Ctrl + C.
  • All project files stay in your project folder, ready to pick up where you left off.

Pause - I'll come back to this later

Shut down running processes to free up memory, but keep all your project files intact.

  • Stop the Vite dev server by pressing Ctrl + C in your terminal.
  • Your project folder and all source files will stay on your machine.
  • When you come back, open the project folder in VS Code and run npm run dev to start the dev server again.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Stop the Vite dev server by pressing Ctrl + C in your terminal.
  • Delete the entire project folder from your file system.
  • The Redux DevTools browser extension can stay installed. It is useful for any future Redux projects.

Mission accomplished

That's a Wrap!

Nice work! You just built a fully functional todo app with React 19 and Redux Toolkit, complete with adding, completing, deleting, and filtering todos through a centralized Redux store.

You've learned how to:

  • Set up a Redux store with configureStore and connect it to React using Provider.
  • Define state slices with createSlice, using Immer-powered reducers and a prepare callback to customize action payloads.
  • Connect five React components to the Redux store using useSelector and useDispatch to read state and dispatch actions.
  • Added localStorage persistence so your todos survive a page refresh.

Ready to quiz yourself?