Using Zustand and TypeScript to Make a To-Do List in React

Today, let’s learn how easy Zustand can make it to manage global client-side state in React by making a to-do list with it.

By Brian Francis

May 28th, 2021

image

When I first came across Zustand, I couldn’t believe how easy it was to use. The learning curve is incredibly thin. If you are familiar with how immutable state works in React, then you will feel right at home working with Zustand. So, without further ado, let’s make ourselves a to-do list.

Step 1: Setting Up Our Project

The first thing that we are going to need to do is to get a base project setup, and we can do this by using the below command in our terminal/Powershell.

npx create-react-app zustand-todo-demo --template typescript

After running this command, this will create a basic starter typescript project for us in React. Next, go ahead and navigate to the newly-created directory.

cd zustand-todo-demo

And run these commands to install the packages we will be using.

npm install zustand @material-ui/core @material-ui/icons uuid

npm install --save-dev @types/uuid

The next thing we need to do is get rid of all the unnecessary files in the src folder and also add a few of our own. Our src folder should look like this when we are done.

src
├── model
│   └── Todo.ts
├── App.tsx
├── index.tsx
├── react-app-env.d.ts
└── todoStore.tsx

With these steps out of the way, it’s now time to open our project up in a code editor. I will be using VS Code, but feel free to use any editor that you prefer.

Now it’s time to start coding!

Step 2: Creating Our Todo Model

First, before introducing Zustand, we will create a model for our to-do list to describe what the data structure of each to-do will look like. Open the Todo.ts file and put the following code into it.

export interface Todo {
  id: string;
  description: string;
  completed: boolean;
}

There’s not much to explain in the above code, but we are defining a type that TypeScript can use to provide us with auto-completion and ensure the proper data is passed around.

With our model now created, it is time to introduce the crux of this tutorial, Zustand.

Step 3: Creating Our Zustand Store

We will now be creating the state management logic for our to-do app. This is where Zustand comes into play.

The below code is what our finished store will look like. Don’t worry if you don’t understand everything, because I will be going over it line-by-line.

import create from "zustand";
import { v4 as uuidv4 } from "uuid";

import { Todo } from "./model/Todo";

interface TodoState {
  todos: Todo[];
  addTodo: (description: string) => void;
  removeTodo: (id: string) => void;
  toggleCompletedState: (id: string) => void;
}

export const useStore = create<TodoState>((set) => ({
  // initial state
  todos: [],
  // methods for manipulating state
  addTodo: (description: string) => {
    set((state) => ({
      todos: [
        ...state.todos,
        {
          id: uuidv4(),
          description,
          completed: false,
        } as Todo,
      ],
    }));
  },
  removeTodo: (id) => {
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    }));
  },
  toggleCompletedState: (id) => {
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id
          ? ({ ...todo, completed: !todo.completed } as Todo)
          : todo
      ),
    }));
  },
}));

Lines 6 — 11 What we have here is an interface defining what our store is going to look like. There are 4 parts to this. We have todos on line 7 which is a list of type Todo. On line 8 we have a method called addTodo which, as you may have guessed, will be used for adding todos to our todo. On line 9 we have a method which we are using to remove todos from our to-do list. On line 10 we have a method that, given the id of our todo item in the list, will toggle the completed state of it.

With our interface now defined, let’s dive into Zustand and how we manage state with it.

Line 13 We are creating our store. You will notice that we will be using the hook naming convention (useStore), and that is because, well, we tap into our store using hooks provided by Zustand. More on this later. We are using a method built into Zustand called create, which, as it sounds, is responsible for creating our store. The create function takes an arrow function that has 3 different parameters (we will only be using the first parameter for this super simple tutorial) and returns an object which will match the interface that we defined above. The parameter set inside of this arrow function is very important, as it allows us to change the state of our store. You will see this in use in the code to follow.

Line 15 What we are doing here is setting the initial state of our to-do list. In this instance, we are setting it to an empty list every time. Perhaps in a more real-world scenario, we would be making a network call or checking data stored locally to determine what the initial state is.

Lines 17 — 28 We are defining how our addTodo method will perform when it is called in our code later. Things of note with this method are that it only needs to take the description for the todo item, we are using a library called uuid, and we are using a spread operator to add items to the array (this is because state is immutable and must be re-created every time). You are going to see on line 18 that we are calling the set method which I described above briefly. Basically, it is an arrow function that takes a parameter that matches the type of our interface that we defined above. This arrow function returns an object, and in that object, we can have what our partially updated state is gonna look like (in this case, it will be our todos being updated). We are not updating things like our different state managing functions, so we don’t have to pass them to the object.

Lines 29 — 32 We are defining our removeTodo method will perform when it is called. All the things that I described about set and immutable state still apply here. Something to note here is that we are passing an id so that we know which element to remove. The way that the removal of elements is working for me here is by using the filter function to filter out all elements from the array that match the id passed (obviously, there should only be one).

Lines 34 — 42 We are defining our toggleCompletedState method. Once again, everything that I discussed above with the set function still applies here. We are leveraging yet another array function built into JavaScript (the Array Map function). This function will take an array (in this case, our todos array) and return another array of the same length, except it will be transformed into a different structure. In our case, what we are doing here with our map function is leveraging another fancy JavaScript feature known as the ternary operator. So if the condition on line 37 is true, then we will run the code on line 38 and if it is false, the code on line 39 will be executed. On line 38 we are flipping the completed state on the object and on line 39 we are simply returning the same object because we don’t want to change the completed state unless it matches our id.

With our store now created, there is really only one last step, and that is to use the code that we just wrote inside of our UI for the React App.

Step 4: Using Zustand Inside of our React App

Before we go any further. I would like to clean up the code in our index.tsx file to look like the below code.

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

With that out of the way, let’s connect Zustand to the UI. Below is the code for what our UI using Zustand is going to look like. No need to worry, as I will be going over the important parts below.

import { useState } from "react";
import {
  Button,
  Checkbox,
  Container,
  IconButton,
  List,
  ListItem,
  ListItemIcon,
  ListItemSecondaryAction,
  ListItemText,
  makeStyles,
  TextField,
  Typography,
} from "@material-ui/core";
import DeleteIcon from "@material-ui/icons/Delete";

import { useStore } from "./todoStore";

const useStyles = makeStyles((theme) => ({
  headerTextStyles: {
    textAlign: "center",
    marginBottom: theme.spacing(3),
  },
  textBoxStyles: {
    marginBottom: theme.spacing(1),
  },
  addButtonStyles: {
    marginBottom: theme.spacing(2),
  },
  completedTodoStyles: {
    textDecoration: "line-through",
  },
}));

function App() {
  const {
    headerTextStyles,
    textBoxStyles,
    addButtonStyles,
    completedTodoStyles,
  } = useStyles();
  const [todoText, setTodoText] = useState("");
  const { addTodo, removeTodo, toggleCompletedState, todos } = useStore();

  return (
    <Container maxWidth="xs">
      <Typography variant="h3" className={headerTextStyles}>
        To-Do's
      </Typography>
      <TextField
        className={textBoxStyles}
        label="Todo Description"
        required
        variant="outlined"
        fullWidth
        onChange={(e) => setTodoText(e.target.value)}
        value={todoText}
      />
      <Button
        className={addButtonStyles}
        fullWidth
        variant="outlined"
        color="primary"
        onClick={() => {
          if (todoText.length) {
            addTodo(todoText);
            setTodoText("");
          }
        }}
      >
        Add Item
      </Button>
      <List>
        {todos.map((todo) => (
          <ListItem key={todo.id}>
            <ListItemIcon>
              <Checkbox
                edge="start"
                checked={todo.completed}
                onChange={() => toggleCompletedState(todo.id)}
              />
            </ListItemIcon>
            <ListItemText
              className={todo.completed ? completedTodoStyles : ""}
              key={todo.id}
            >
              {todo.description}
            </ListItemText>
            <ListItemSecondaryAction>
              <IconButton
                onClick={() => {
                  removeTodo(todo.id);
                }}
              >
                <DeleteIcon />
              </IconButton>
            </ListItemSecondaryAction>
          </ListItem>
        ))}
      </List>
    </Container>
  );
}

export default App;

I’m not going to be touching on how Material-UI works in this walkthrough as it is outside the scope of this tutorial, but essentially it is going to allow us to easily style and layout our app. Without further, ado let’s see how Zustand works within our UI. You are going to see on line 44 that we are calling the function useStore() which is going to tap into our Zustand store, which we created above.

Thanks to object destructuring, we can pull out all the functions as well as the state which we will be using below. On line 67 we are calling the addTodo method which is going to take a description and create a new to-do item on the list. On line 81 we are toggling the completed state of our to-do item. This gets toggled whenever we check or un-check a checkbox on each list item of our to-do list. On line 93 we are removing a to-do item from the list. This action will happen when the trash icon is clicked on.

And finally, you are going to see on line 75 that we are looping over our list and generating a ListItem for each item in the to-do list.

Wrapping Up

Zustand is perhaps the simplest and least boiler-plately state-management solution that I have yet to use in React. It’s enjoyable to work with and doesn’t have a steep learning curve for those who already understand React.

As always, I’m open to any comments and feedback that you might have. Feel free to share your favorite React state-management library. I’d love to hear.

Additional Tutorials

Here are some tutorials for other state-management libraries in React for those of you who are interested. Making a React To-Do List With Redux Toolkit in TypeScript

Managing Local State in React with Apollo, Immer, and TypeScript

Source Code on Github

13bfrancis/zustand-tut-1

Further Reading

Sharing Types Between Your Frontend and Backend Applications



Continue Learning