DAY 37: Mastered Zustand From Basics to Full CRUD Todo App Implementation | ReactJS

Ritik KumarRitik Kumar
13 min read

🚀 Introduction

Welcome to Day 37 of my Web Development Journey!
After building a solid foundation in HTML, CSS, and JavaScript, I’ve been diving into ReactJS — a powerful library for creating dynamic, interactive user interfaces.

Over the past few days, I started learning Zustand for React state management — super simple yet incredibly powerful! I explored how to create a store, access state, update state, use middlewares, and handle async actions.

To stay consistent and share my learnings, I’m documenting this journey publicly. Whether you’re just starting with React or need a refresher, I hope this blog offers something valuable!

Feel free to follow me on Twitter for real-time updates and coding insights.

📅 Here’s What I Covered Over the Last 3 Days

Day 34

  • Deep dive into Zustand
  • Installing Zustand
  • Creating a store
  • Accessing state in components
  • Updating state

Day 35

  • Accessing state outside components (get)
  • Middlewares in Zustand
  • Common built-in middlewares

Day 36

  • Derived state
  • Async actions
  • Best practices
  • Built a Todo App with full CRUD functionality

Let’s dive into these concepts and the app in more detail below 👇


1. Zustand:

Zustand is a small, fast, and scalable state management library for React applications. It offers a simple and intuitive API to manage global state without the boilerplate typically associated with other libraries like Redux.

Key Features of Zustand:

  • Minimalistic and Lightweight:
    Zustand has a tiny footprint and minimal setup, making it easy to integrate into any React project without overhead.

  • No Boilerplate:
    Unlike Redux, Zustand avoids complex actions, reducers, and dispatch mechanisms. Instead, it uses simple functions to create and manipulate state.

  • Direct State Access:
    Components can subscribe to specific parts of the state, ensuring efficient re-rendering only when relevant state changes occur.

  • Built-in Support for Middleware:
    Zustand supports middlewares out of the box, which can be used for logging, persistence, or handling asynchronous logic.

  • Works Seamlessly with React Hooks:
    Zustand leverages React’s hooks API, providing a natural and modern way to manage state in functional components.

  • Flexible and Scalable:
    It can handle simple use cases with a single store as well as complex applications with multiple stores.

Installing Zustand:

Zustand is available as a package on NPM for use:

# NPM
npm install zustand
# Or, use any package manager

Creating a Store:

In Zustand, a store is where you define your application’s state and the functions to update that state.

It acts like a centralized place to keep and manage your state, making it accessible across components without prop drilling or complex setups.

store is a hook, We can put anything in it : primitives, objects, functions.
The set function merges state.

Here’s a simple example where we create a counter store using Zustand’s create function:

import create from "zustand";

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

Explanation:

  • We import the create function from Zustand.
  • The create function takes a function as an argument. This function returns an object that represents the initial state of the store — including both state properties and actions (functions to update the state).
  • useCounterStore is the custom hook created by create, which holds this state and actions.
  • In this example, the state includes a count property initialized to 0.
  • We define two actions, increment and decrement, which update the count using the set method provided by Zustand.
  • By calling useCounterStore inside a React component, we can easily access the current state and invoke these actions to update it.

Accessing store in components:

To use the Zustand store in our React components, we simply call the custom hook created by create (e.g., useCounterStore). This hook lets us:

  • Access the current state values.
  • Call actions to update the state.

Here’s an example of accessing and using the count state along with the increment and decrement actions inside a React component:

import { useCounterStore } from "./store";

const Counter = () => {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
};

export default Counter;
  • The selector function (state) => state.count lets us pick only the part of the state we need.
  • This approach helps optimize component re-renders by subscribing only to the relevant parts of the store.
  • We can access multiple state values and actions by calling the hook multiple times or destructuring them together.

Updating State in Zustand:

There are two main ways to update state in Zustand stores: Functional Update and Direct Update. Let's explore both using a simple counter example.

Functional Update

In this method, we pass a function to the set method that receives the current state and returns a new partial state. This approach is useful when the new state depends on the previous state.

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
  • Here, set takes a function (state) => ({ count: state.count + 1 }).
  • This function receives the current state and returns an updated state object.
  • It ensures we always work with the latest state, avoiding bugs in concurrent updates.

Direct Update

We can also update state by passing an object directly to set when the new state does not depend on the previous state.

const useCounterStore = create((set) => ({
  count: 0,
  reset: () => set({ count: 0 }),
}));
  • Here, set receives an object { count: 0 } that directly replaces the relevant part of the state.
  • This method is straightforward for simple assignments or resets.

Accessing state outside components (get):

Sometimes, we may need to access the store's state outside of React components, such as in utility functions, event listeners, or API calls.

Zustand provides the get method for this purpose.

import { create } from 'zustand';

const useCounterStore = create((set, get) => ({
  count: 0,
  increment: () => set({ count: get().count + 1 }),
}));

// Accessing state outside a component
console.log(useCounterStore.getState().count); // 0

// Updating state outside a component
useCounterStore.getState().increment();
console.log(useCounterStore.getState().count); // 1

Explanation:

  • We define our store with two arguments in the create function:
    • set → for updating state
    • get → for reading the current state
  • get() allows us to read the store's state anywhere, even outside React.
  • In this example:
    • useCounterStore.getState().count fetches the current value of count.
    • We can call actions (like increment) directly from the store.
  • This is especially useful for logic that doesn’t directly involve a React component.

Middlewares in Zustand:

Middlewares in Zustand are higher-order functions that enhance our store with extra capabilities such as persistence, logging, or devtools integration.
They wrap around our store definition and intercept state updates or actions to provide additional functionality.

Syntax:

import { create } from "zustand";
import { middlewareName } from "zustand/middleware";

const useStore = create(
  middlewareName((set, get) => ({
    // state
    count: 0,
    // actions
    increment: () => set(state => ({ count: state.count + 1 }))
  }))
);

Common Built-in Middlewares:

  1. persist:
    • Saves our store state to localStorage or another storage mechanism.
    • Automatically rehydrates the store state on page reload.
import { persist } from "zustand/middleware";

const useCounterStore = create(
  persist(
    (set) => ({
      count: 0,
      increment: () => set(state => ({ count: state.count + 1 }))
    }),
    {
      name: "counter-storage", // key in localStorage
    }
  )
);
  1. devtools:
    • Integrates our store with Redux DevTools for easier debugging.
    • Lets us track state changes and dispatched actions.
import { devtools } from "zustand/middleware";

const useCounterStore = create(
  devtools((set) => ({
    count: 0,
    increment: () => set(state => ({ count: state.count + 1 }))
  }), { name: "CounterStore" })
);

Middlewares make Zustand stores more powerful and easier to manage, especially for persistence, debugging, and logging state changes.

Derived State (Computed Values):

Derived state, also called computed state, is a value that depends on the store's existing state but is calculated dynamically rather than stored directly. This helps avoid redundant state and keeps your store clean.

Example: Double Counter Value

import create from "zustand";

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  double: () => {
    const state = useCounterStore.getState();
    return state.count * 2;
  },
}));
Explanation:
  • count is the primary state.
  • increment and decrement update the count.
  • double is a derived state computed from count without storing it separately.
  • Calling useCounterStore.getState().double() returns twice the current count.

This approach ensures that derived values always reflect the latest state without introducing unnecessary stored properties.

Async Actions in Zustand:

Zustand allows us to define asynchronous actions directly in our store, which is useful for fetching data or performing other async tasks.

In this example, we have a useUserStore that manages a list of users:

import { create } from "zustand";

export const useUserStore = create((set) => ({
  users: [],
  loading: false,
  error: "",
  fetchUsers: async (url) => {
    if (!url) {
      set({ error: "Url is Required!" });
      return;
    }
    try {
      set({ loading: true, error: "" });
      const res = await fetch(url);
      if (!res.ok) throw new Error("Failed to fetch users");
      const data = await res.json();
      if (!data.length) {
        set({ error: "No data Found", loading: false });
        return;
      }
      set({ users: data, loading: false });
    } catch (err) {
      set({ error: err.message, loading: false });
    }
  },
}));

Explanation:

  • users, loading, and error are part of the store's state.
  • fetchUsers is an async action that accepts a url to fetch data from.
  • Before fetching, it sets loading: true and clears any previous errors.
  • The fetch call retrieves data from the API, and set updates the state accordingly.
  • If the URL is missing, the response fails, or no data is returned, error is set with a descriptive message.
  • Once the data is successfully fetched, users is updated and loading is set to false.

This pattern allows us to manage async state changes seamlessly within Zustand, keeping your components clean and reactive.

When to Use Zustand?

Zustand is a great choice in the following scenarios:

  1. Simple Global State Management – When we need a lightweight solution without the boilerplate of Redux.
  2. Intermediate React Projects – Ideal for apps that need more than useState or useReducer but don’t require a full-scale state management library.
  3. Async Data Fetching – When managing API calls and loading/error states across components.
  4. Derived or Computed State – When we need values computed from the store without duplicating state.
  5. Debugging & Persistence Needs – With middlewares like persist and devtools, it becomes easy to debug and persist state across sessions.

Best Practices with Zustand:

  1. Keep Stores Small and Focused – Create multiple small stores for different domains instead of one giant store.
  2. Use Selectors – Access only the state we need in components to avoid unnecessary re-renders.
  3. Leverage Middlewares Wisely – Use persist for saving state, devtools for debugging, but avoid overusing middlewares that aren’t necessary.
  4. Organize Async Actions Clearly – Keep fetches and async logic inside the store to maintain clean components.
  5. Avoid Storing Derived State – Compute values on-the-fly using derived state instead of storing them redundantly.
  6. Use get for Non-Component Logic – Access store state outside components when needed without triggering re-renders.

Final Thoughts:

  • Zustand is a lightweight, flexible, and powerful state management library for React.
  • It simplifies state handling by providing a clean API with create, set, and get, while supporting middlewares, derived state, and async actions.
  • Using Zustand allows us to manage both simple and complex state efficiently, keeping our components clean.
  • It helps avoid the boilerplate of other state management solutions.
  • By following best practices—like keeping stores focused, using selectors, and leveraging middlewares wisely—we can build scalable and maintainable React applications with ease.

2. Implementing CRUD Functionality in Todo App Using Zustand:

In this section, I’ll explain how I leveraged Zustand to manage the state of a Todo App. The focus is mainly on how the store is structured, how state and actions are accessed in components, and how the app handles CRUD operations efficiently.

1. Creating the Store

The useTodoStore is our main Zustand store. It uses the persist middleware to save todos in localStorage, so the state remains even after a page refresh.

Key points of the store:

  • State:
    • todos: an array of todo objects { id, name, completed }.
  • Actions:
    • addTodo: Adds a new todo with a unique ID and completed: false.
    • toggleTodo: Toggles the completed state of a specific todo.
    • deleteTodo: Removes a todo by its ID.
    • editTodo: Updates the name of a specific todo.

Code:

export const useTodoStore = create(
  persist(
    (set) => ({
      todos: [],
      addTodo: (todo) => {
        set((state) => ({
          todos: [
            ...state.todos,
            {
              id: crypto.randomUUID(),
              name: todo,
              completed: false,
            },
          ],
        }));
      },
      toggleTodo: (id) => {
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
          ),
        }));
      },
      deleteTodo: (id) => {
        set((state) => ({
          todos: state.todos.filter((todo) => todo.id !== id),
        }));
      },
      editTodo: (todoName, id) => {
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, name: todoName } : todo
          ),
        }));
      },
    }),
    {
      name: "todos",
      storage: createJSONStorage(() => localStorage),
    }
  )
);

Explanation:

  • create initializes the store with state and actions.
  • persist middleware saves the store in localStorage.
  • set is used to update the state immutably.
  • Each action (addTodo, toggleTodo, deleteTodo, editTodo) updates the todos array appropriately.

2. Accessing State in Components

We use the useTodoStore hook inside React components to read state or dispatch actions.
Zustand’s selector function allows subscribing to only the part of the state needed, which improves performance.

  • Example in the main component:

    const todos = useTodoStore((state) => state.todos);
    

    This fetches the current todos array.

  • Components like TodoList and TodoItem also use selectors to pick only the actions they need:

    const toggleTodo = useTodoStore((state) => state.toggleTodo);
    const deleteTodo = useTodoStore((state) => state.deleteTodo);
    

3. GitHub Repository:

Explore the full source code of this Todo App on GitHub: Todo App Repository


3. What’s Next:

I’m excited to keep growing and sharing along the way! Here’s what’s coming up:

  • Posting new blog updates every 3 days to share what I’m learning and building.
  • Diving deeper into Data Structures & Algorithms with Java — check out my ongoing DSA Journey Blog for detailed walkthroughs and solutions.
  • Sharing regular progress and insights on X (Twitter) — feel free to follow me there and join the conversation!

Thanks for being part of this journey!


4. Conclusion:

In this blog, we explored Zustand in depth — from creating stores and updating state to using middlewares, derived state, and async actions.

We also implemented a Todo App with full CRUD functionality, demonstrating how Zustand enables efficient state management and clean component subscriptions.

Key Takeaways:

  • Zustand offers a simple and flexible approach to managing state in React applications.
  • Its selector functions and middlewares optimize performance and enable state persistence.
  • Using Zustand for CRUD operations shows how to maintain clean and reactive state without prop drilling or boilerplate.
  • With a solid understanding of both theory and practical usage, we can confidently integrate Zustand into your React projects.

💻 Explore the full source code on GitHub: Todo App Repository

Thanks for reading! Feel free to connect or follow along as I continue building and learning in React.

0
Subscribe to my newsletter

Read articles from Ritik Kumar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ritik Kumar
Ritik Kumar

👨‍💻 Aspiring Software Developer | MERN Stack Developer.🚀 Documenting my journey in Full-Stack Development & DSA with Java.📘 Focused on writing clean code, building real-world projects, and continuous learning.