DEV Community

loading...
Cover image for ⚗️ React Redux CRUD app for beginners [with Hooks]

⚗️ React Redux CRUD app for beginners [with Hooks]

sanderdebr profile image sanderdebr ・9 min read

Redux has been and still is the most used library for managing state within React applications. It provides many benefits but can be daunting to learn for beginners learning React. In this tutorial we will build a simple CRUD app using React and Redux.

Checkout the finished app here
Checkout the code here

Contents

  1. 🔨 Setup
  2. 👪 Loading users from state
  3. ➕ Adding a new user
  4. 🔧 Editing a user
  5. 🗑️ Deleting a user
  6. ✨ Loading new users asynchronously

Redux vs React Context API & useReducer

Last week I wrote a tutorial on how to use React's Context API and useReducer hook for managing state. The combinations of these two are awesome and should be, in my opinion, used for small to medium sized applications with not too complex state logic. When your app grows in size, or you want to be properly prepared for that, it's recommended to switch to Redux.

Benefits of Redux

Why would you add another library for managing state? I thought React is managing the state already? That's true but imagine you have many components and pages and they all need to fetch data from different API's and data sources and manage the state of how the user is interacting with that data and the interface. Quickly your app state can become a mess. The main benefits that I have discovered are:

  • Global state: Redux keeps all the state in one store, the single source of truth.
  • Predictable: Using the single store your app has little problems syncing your current state and actions with other parts of your application.
  • Maintainability: Because Redux has strict guidelines on how to structure the code, your code will be easier to mainting.

Let's get started!

🔨 1. Setup

Let's start by creating a new React app with the default configuration:
$ npx create-react-app redux-crud-app

First let's remove all the files inside the /src folder except for App.js and index.js. Clear out App.js and let's only return a word for now. Run the app with $ npm run start.

App.js

function App() {
  return (
    <h1>Hi</h1>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's add a simple CSS library so our app will look nice. I will use Skeleton CSS for this tutorial. Just go to index.html and add the following line before the ending tag:
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" />

The text should now be styled on your localhost. Let's add the standard React router package to handle our different pages as a development dependency:

$ npm install react-router-dom --save

App.js

import { Route, BrowserRouter as Router, Switch } from "react-router-dom";

import React from "react";
import { UserList } from "./features/users/UserList";

export default function App() {
  return (
    <Router>
      <div>
        <Switch>
          <Route path="/">
            <UserList />
          </Route>
          <Route path="/add-user">
            <h1>Add user</h1>
          </Route>
          <Route path="/edit-user">
            <h1>Edit user</h1>
          </Route>
        </Switch>
      </div>
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

And add a the UsersList component for the layout:

/features/users/UserList.jsx

export function UserList() {
  return (
    <div className="container">
      <div className="row">
        <h1>Redux CRUD User app</h1>
      </div>
      <div className="row">
        <div className="two columns">
          <button className="button-primary">Load users</button>
        </div>
        <div className="two columns">
          <button className="button-primary">Add user</button>
        </div>
      </div>
      <div className="row">
        <table class="u-full-width">
          <thead>
            <tr>
              <th>ID</th>
              <th>Name</th>
              <th>Email</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>1</td>
              <td>Dave Gamache</td>
              <td>dave@gmail.com</td>
              <td>
                <button>Delete</button>
                <button>Edit</button>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

👪 2. Loading users from state

First we have to add the redux store to our application. Let's install react-redux and the redux toolkit:
$ npm install @reduxjs/toolkit react-redux --save

Then create a file store.js with the following code:

store.js

import { configureStore } from "@reduxjs/toolkit";

export default configureStore({
  reducer: {},
});
Enter fullscreen mode Exit fullscreen mode

Later we'll add our Redux functions to mutate the state (reducers) here. Now we need to wrap our application inside the store by using Redux's provider wrapper:

index.js

import App from "./App";
import { Provider } from "react-redux";
import React from "react";
import ReactDOM from "react-dom";
import store from "./store";

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

Next up let's add the redux state and add our users to it. Then we'll fetch this state inside our UserList component.

We'll divide up our code into features. In our app we'll only have on feature, the users. Redux calls the collection of the logic per feature slices. Let's create one:

/features/users/usersSlice

import { createSlice } from "@reduxjs/toolkit";

const initialState = [
  { id: "1", name: "Dave Patrick", email: "dave@gmail.com" },
  { id: "2", name: "Hank Gluhwein", email: "hank@gmail.com" },
];

const usersSlice = createSlice({
  name: "users",
  initialState,
  reducers: {},
});

export default usersSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Now we'll add our user slice (user part of state) to our store so we can access it anywhere in our application. Redux automatically creates the .reducer function of slices. So we'll add the user slice as follows:

store.js

import { configureStore } from "@reduxjs/toolkit";
import usersReducer from "./features/users/usersSlice";

export default configureStore({
  reducer: {
    users: usersReducer,
  },
});
Enter fullscreen mode Exit fullscreen mode

I would recommend using Redux DevTools to see the current state and its differences

Lastly, let's render our user table based on our Redux state. To access the state in Redux we have to use the useSelector hook. This is just a function that returns a piece of the state. We can decide which piece we want by providing it with a function.

We'll ask for the users object in our state. Then we render this array as list of users.

UserList.jsx

import { useSelector } from "react-redux";

export function UserList() {
  const users = useSelector((state) => state.users);

  return (
    ...
          <tbody>
            {users.map(({ id, name, email }, i) => (
              <tr key={i}>
                <td>{id}</td>
                <td>{name}</td>
                <td>{email}</td>
                <td>
                  <button>Delete</button>
                  <button>Edit</button>
                </td>
              </tr>
            ))}
          </tbody>
    ...
  );
}
Enter fullscreen mode Exit fullscreen mode

And that's how we render the state on the page with Redux, pretty doable right? 😃

➕ 3. Adding a new user

First let's create a basic form with hooks to manage the input fields. Notice that we are not using Redux here to manage the input fields state. This is because you do not need to put everything in Redux, actually it is better to keep state that is only needed in one component in that component itself. Input fields are the perfect example.

/features/users/AddUser.jsx

import { useState } from "react";

export function AddUser() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  const handleName = (e) => setName(e.target.value);
  const handleEmail = (e) => setEmail(e.target.value);

  return (
    <div className="container">
      <div className="row">
        <h1>Add user</h1>
      </div>
      <div className="row">
        <div className="three columns">
          <label for="nameInput">Name</label>
          <input
            className="u-full-width"
            type="text"
            placeholder="test@mailbox.com"
            id="nameInput"
            onChange={handleName}
            value={name}
          />
          <label for="emailInput">Email</label>
          <input
            className="u-full-width"
            type="email"
            placeholder="test@mailbox.com"
            id="emailInput"
            onChange={handleEmail}
            value={email}
          />
          <button className="button-primary">Add user</button>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

On submit we want to add the user to the state and sent the user back to the UserList component. If something fails, we'll display an error.

First we add a method/function to our Redux user slice. This method is for mutating the state, which Redux calls a reducer. Our method inside reducers receives the user state and the action, in this case the user form field values.

Redux automatically creates an action for us that we can use to call this function.

usersSlice.js

import { createSlice } from "@reduxjs/toolkit";

const initialState = [
  { id: "1", name: "Dave Patrick", email: "dave@gmail.com" },
  { id: "2", name: "Hank Gluhwein", email: "hank@gmail.com" },
];

const usersSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    userAdded(state, action) {
      state.push(action.payload);
    },
  },
});

export const { userAdded } = usersSlice.actions;

export default usersSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

To use this action function, we need to import the useDispatch hook from Redux. We'll check if the fields are not empty and then dispatch the userAdded action with our fields. To generate the correct user ID, we grab the length of our users array in the state and add one to it.

AddUser.jsx

import { nanoid } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import { useHistory } from "react-router-dom";
import { useState } from "react";
import { userAdded } from "./usersSlice";

export function AddUser() {
  const dispatch = useDispatch();
  const history = useHistory();

  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [error, setError] = useState(null);

  const handleName = (e) => setName(e.target.value);
  const handleEmail = (e) => setEmail(e.target.value);

  const usersAmount = useSelector((state) => state.users.length);

  const handleClick = () => {
    if (name && email) {
      dispatch(
        userAdded({
          id: usersAmount + 1,
          name,
          email,
        })
      );

      setError(null);
      history.push("/");
    } else {
      setError("Fill in all fields");
    }

    setName("");
    setEmail("");
  };

return (
   ...
   {error && error}
          <button onClick={handleClick} className="button-primary">
            Add user
          </button>
   ...
Enter fullscreen mode Exit fullscreen mode

We can add users to the store, awesome!

🔧 4. Editing a user

To edit a user we'll update our edit button first by linking it to the dynamic /edit-user/{id} page inside our UserList component:

<Link to={`/edit-user/${id}`}>
   <button>Edit</button>
</Link>
Enter fullscreen mode Exit fullscreen mode

Then we'll add the new reducer to our Redux slice. It will find the user within our state and update it if it exists.

usersSlice.js

const usersSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    userAdded(state, action) {
      state.push(action.payload);
    },
    userUpdated(state, action) {
      const { id, name, email } = action.payload;
      const existingUser = state.find((user) => user.id === id);
      if (existingUser) {
        existingUser.name = name;
        existingUser.email = email;
      }
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Our EditUser.jsx file will look very similar to AddUser.jsx except here we take the user id from the URL path using the useLocation hook from react-router-dom:

EditUser.jsx

import { useDispatch, useSelector } from "react-redux";
import { useHistory, useLocation } from "react-router-dom";

import { useState } from "react";
import { userUpdated } from "./usersSlice";

export function EditUser() {
  const { pathname } = useLocation();
  const userId = pathname.replace("/edit-user/", "");

  const user = useSelector((state) =>
    state.users.find((user) => user.id === userId)
  );

  const dispatch = useDispatch();
  const history = useHistory();

  const [name, setName] = useState(user.name);
  const [email, setEmail] = useState(user.email);
  const [error, setError] = useState(null);

  const handleName = (e) => setName(e.target.value);
  const handleEmail = (e) => setEmail(e.target.value);

  const handleClick = () => {
    if (name && email) {
      dispatch(
        userUpdated({
          id: userId,
          name,
          email,
        })
      );

      setError(null);
      history.push("/");
    } else {
      setError("Fill in all fields");
    }

    setName("");
    setEmail("");
  };

  return (
    <div className="container">
      <div className="row">
        <h1>Edit user</h1>
      </div>
      <div className="row">
        <div className="three columns">
          <label htmlFor="nameInput">Name</label>
          <input
            className="u-full-width"
            type="text"
            placeholder="test@mailbox.com"
            id="nameInput"
            onChange={handleName}
            value={name}
          />
          <label htmlFor="emailInput">Email</label>
          <input
            className="u-full-width"
            type="email"
            placeholder="test@mailbox.com"
            id="emailInput"
            onChange={handleEmail}
            value={email}
          />
          {error && error}
          <button onClick={handleClick} className="button-primary">
            Save user
          </button>
        </div>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

🗑️ 5. Deleting a user

I would like to invite to figure this one out for yourself! It will be a good excerise to practise what we've learned up till this point.

Hereby my solution for the reducer:

    userDeleted(state, action) {
      const { id } = action.payload;
      const existingUser = state.find((user) => user.id === id);
      if (existingUser) {
        return state.filter((user) => user.id !== id);
      }
    },
Enter fullscreen mode Exit fullscreen mode

You can check the full solution with my code on github.

✨ 6. Loading new users asynchronously

Heads up - the following part is a bit trickier but very valuable to learn!

A nice feature would be to load in users from an external API. We will use this free one: https://jsonplaceholder.typicode.com/users.

Redux from itself is running code only synchronously. To handle ascync code it was most common to use something called a redux-thunk, which is just a simple function that allows async code as actions.

Nowadays, Redux has a built in feature to add async code. Many tutorials still use redux-thunk but the new configureStore function from redux has this built in already.

Let's add the API fetch to our usersSlice:

export const fetchUsers = createAsyncThunk("fetchUsers", async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/users");
  const users = await response.json();
  return users;
});
Enter fullscreen mode Exit fullscreen mode

Then inside our slice we will add a property called extraReducers that holds a couple functions to handle the return of the API:

  • pending
  • fulfilled
  • rejected

Our API call returns a Promise which is an object that represents the status of an asynchronous operation, in our case an API call. Based on the Promise status we'll update our state.

usersSlicejs

const usersSlice = createSlice({
  name: "users",
  initialState: {
    entities: [],
    loading: false,
  },
  reducers: { ... },
  extraReducers: {
    [fetchUsers.pending]: (state, action) => {
      state.loading = true;
    },
    [fetchUsers.fulfilled]: (state, action) => {
      state.loading = false;
      state.entities = [...state.entities, ...action.payload];
    },
    [fetchUsers.rejected]: (state, action) => {
      state.loading = false;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

We want to fetch this array of users as soon as our app loads and every time a user clicks on the LOAD USERS button.

To load it as soon as our app loads, let's dispatch it before our component inside index.js:

store.dispatch(fetchUsers())

And to dispatch it on our button:

onClick={() => dispatch(fetchUsers())}

That's it! We finished building our CRUD app using React, Redux and Hooks.

You can find the full source code here.
And you can checkout the final app here.

Thanks for following this tutorial, make sure to follow me for more! 😀

Discussion (0)

pic
Editor guide