Mastering Redux: Building a Practical CRUD Application in React

May 31, 2020 · 6 min read

Update — April 4, 2026: This post has been updated to improve clarity and structure. Key changes include refined code explanations, corrected command-line references, and optimized heading hierarchy for better readability.

hen you start learning Redux, grasping how it works can be confusing at first. Maybe you come from an object-oriented language and are used to mutating state with classes, or you’ve never worked with ECMAScript 6, so the syntax and patterns you see online feel unfamiliar.

In this post, I’ll walk through a basic Redux CRUD example (create, read, update, delete) without persisting data. The goal is to help you understand how Redux works and why it can be useful in certain scenarios.

First, let’s scaffold a React app using Create React App:

yarn create react-app crud-todo

The todo example is one of the most straightforward use cases, but feel free to use anything you like (movies, books, etc.). This example won’t consume an API to keep things simple. Using an API would introduce an additional layer for handling side effects, which we’ll skip for now.

Let’s add the dependencies we’re going to use:

yarn add redux react-redux redux-logger react-router-dom reselect bootstrap

Next, let’s create our reducer with an initial state and the necessary actions:

// redux/modules/todos.js
const NEW_TODO = "crud-todo/todos/NEW_TODO";
const EDIT_TODO = "crud-todo/todos/EDIT_TODO";
const DESTROY_TODO = "crud-todo/todos/DESTROY_TODO";

const initialState = {
  todos: [
    {
      id: "1",
      text: "Take the dog for a walk",
    },
    {
      id: "2",
      text: "Finish Homework",
    }
  ]
};

export default function reducer(state = initialState, action = {}) { return state }

Now let’s create the root reducer, the store, and add the Provider in App.js:

// redux/rootReducer.js
import { combineReducers } from "redux";
import todosReducer from "./modules/todos";

const rootReducer = combineReducers({
  todosReducer,
});

export default rootReducer;
// redux/setupStore.js
import { createStore } from "redux";
import { createLogger } from "redux-logger";

import rootReducer from "./rootReducer";

export default function configureStore(initialState = {}) {
  const middlewares = [];

  if (process.env.NODE_ENV === "development") {
    const logger = createLogger({ collapsed: true });
    middlewares.push(logger);
  }

  return createStore(
    rootReducer,
    initialState,
  );
}
// App.js
import React from 'react';
import { Provider } from "react-redux";
import setupStore from "./redux/setupStore";
import './App.css';

const store = setupStore();

function App() {
  return (
    <Provider store={store}>
      <h1>Todos</h1>
    </Provider>
  );
}

export default App;

Now that the boilerplate is set up, let’s create a basic router for navigating between pages:

import React from "react";
import {
  BrowserRouter as Router,
  Switch,
  Route,
  NavLink
} from "react-router-dom";
import TodoList from "../components/TodoList"
import NewTodo from "../components/NewTodo"
import EditTodo from "../components/EditTodo"

export default function AppRouter() {
  return (
    <Router>
      <div>
        <nav className="navbar navbar-expand-lg navbar-light bg-light">
          <NavLink className="navbar-brand" to="/">Todo List</NavLink>
          <button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span className="navbar-toggler-icon"></span>
          </button>

          <div className="collapse navbar-collapse" id="navbarSupportedContent">
            <ul className="navbar-nav mr-auto">
              <li className="nav-item active">
                <NavLink className="nav-link" to="/new">New Todo</NavLink>
              </li>
            </ul>
          </div>
        </nav>

        <Switch>
          <Route exact path="/">
            <TodoList />
          </Route>
          <Route path="/new">
            <NewTodo />
          </Route>
          <Route path="/edit/:id">
            <EditTodo />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

This is mostly based on the React Router example. Next, let’s create the todo list component:

// components/TodoList.js
import React from 'react';
import { connect } from "react-redux";
import { Link } from 'react-router-dom';

function TodoList({ todos }) {
  return (
    <div className="container">
      <h1>Todo List</h1>
      <table className="table">
        <thead className="thead-dark">
          <tr>
            <th scope="col">Text</th>
            <th scope="col">Created At</th>
            <th scope="col">Updated At</th>
            <th scope="col">Actions</th>
          </tr>
        </thead>
        <tbody>
        {todos.length > 0 && todos.map(({ id, text, createdAt, updatedAt }) => {
          return (
            <tr key={id}>
              <th scope="row">{text}</th>
              <td>{createdAt.toISOString()}</td>
              <td>{updatedAt.toISOString()}</td>
              <td>
                <Link className="btn btn-link" to={`edit/${id}`}>Edit</Link>
                /
                <button className="btn btn-link" onClick={() => {}}>Delete</button>
              </td>
            </tr>
          )
        })}
        </tbody>
      </table>
    </div>
  );
}

const mapStateToProps = state => ({
  todos: state.todosReducer.todos,
});

export default connect(mapStateToProps)(TodoList);

This component connects to Redux, retrieves todos, and renders them. It also includes a link to edit each todo.

Next, let’s create a reusable form component:

// components/TodoForm.js
import React, { useState } from 'react';

function TodoForm(props) {
  const { todo, action } = props;
  const [text, setText] = useState(todo.text ? todo.text : "");

  const onSubmit = (event) => {
    event.preventDefault();
    action(todo.id ? { id: todo.id, text } : { text })
  }

  return (
    <form onSubmit={onSubmit}>
      <div className="form-group">
        <input className="form-control"
          type="text"
          value={text}
          onChange={(event) => setText(event.target.value)}
        />
      </div>
      <input
        type="submit"
        className="btn btn-success"
      />
    </form>
  );
}

export default TodoForm;

This component handles both creating and editing todos.

Now let’s create the new todo component:

import React from 'react';
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import TodoForm from './TodoForm';
import { newTodo } from '../redux/modules/todos';

function NewTodo({ newAction }) {
  return (
    <div className="container">
      <h1>New Todo</h1>
      <TodoForm action={newAction} todo={{}} />
    </div>
  );
}

const mapDispatchToProps = dispatch => {
  return bindActionCreators(
    {
      newAction: newTodo,
    },
    dispatch,
  );
};

export default connect(null, mapDispatchToProps)(NewTodo);

Now update the reducer to handle creating todos:

import { v4 as uuidv4 } from 'uuid';

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case NEW_TODO:
      const newTodo = { id: uuidv4(), text: action.text, createdAt: new Date(), updatedAt: new Date() }
      return {
        ...state,
        todos: [...state.todos, newTodo]
      }
    default:
      return state
  }
}

export function newTodo({ text }) {
  return { type: NEW_TODO, text };
}

The key idea here is immutability. We don’t mutate the existing state—instead, we return a new copy.

Next, the edit component:

// components/EditTodo.js
import React from 'react';
import TodoForm from './TodoForm';
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import { withRouter } from 'react-router-dom';
import { getTodoById, editTodo } from '../redux/modules/todos';

function EditTodo({ todo, editAction }) {
  return (
    <div className="container">
      <h1>Edit Todo</h1>
      <TodoForm action={editAction} todo={todo} />
    </div>
  );
}

function mapStateToProps(state, ownProps) {
  const { params } = ownProps.match;
  const { id } = params;

  return { todo: getTodoById(state, id) }
}

const mapDispatchToProps = dispatch => {
  return bindActionCreators(
    {
      editAction: editTodo,
    },
    dispatch,
  );
};

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(EditTodo));

Reducer updates:

import { createSelector } from "reselect";

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case EDIT_TODO:
      const { editedTodo } = action;
      const editedTodos = state.todos.map((todo) => {
        if (todo.id === editedTodo.id) {
          return { ...todo, text: editedTodo.text, updatedAt: new Date() }
        }
        return { ...todo }
      })
      return {
        ...state,
        todos: editedTodos
      }
    default:
      return state
  }
}

export function editTodo(editedTodo) {
  return { type: EDIT_TODO, editedTodo };
}

const getTodo = (state, id) =>
  state.todosReducer.todos.find(todo => id === todo.id);

export const getTodoById = createSelector(
  [getTodo],
  todo => todo,
);

Finally, delete functionality:

case DESTROY_TODO:
  const { id } = action;
  const newTodos = state.todos.filter(todo => todo.id !== id);
  return {
    ...state,
    todos: newTodos
  }

Redux is fundamentally about transforming state in a predictable, immutable way. Once you understand that, everything else becomes much simpler.

Helpful? /