A Practical Beginner Guide to Zustand - Building Your First Store Step by Step
ZustandReactTutorial

A Practical Beginner Guide to Zustand - Building Your First Store Step by Step

January 26, 2025
10 min read
Salman Izhar

A Practical Beginner Guide to Zustand - Building Your First Store Step by Step

If you've never used Zustand before, you're in the right place.

We're going to build a real thing together-a todo app. Not a complicated one. Just enough to understand how Zustand works and why it's so nice to use.

No jargon. No magic. Just practical code you can actually use.

What You'll Need

Before we start, make sure you have:

  • A React project (Create React App, Vite, Next.js-doesn't matter)
  • Basic React knowledge (hooks, components, props)
  • 15 minutes

That's it.

Step 1: Install Zustand

Open your terminal and run:

npm install zustand

Done. That's the whole installation. No Redux Toolkit, no middleware, no extras. Just one package.

Step 2: Create Your First Store

Let's create a file called todoStore.js (or .ts if you're using TypeScript).

Here's the whole store:

import { create } from 'zustand'

const useTodoStore = create((set) => ({
  // State
  todos: [],
  
  // Actions
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, done: false }]
  })),
  
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    )
  })),
  
  deleteTodo: (id) => set((state) => ({
    todos: state.todos.filter(todo => todo.id !== id)
  })),
}))

export default useTodoStore

Let's break this down in plain English.

What's Happening Here?

create is the Zustand function that makes a store.

Inside it, you pass a function that gets set as a parameter. Think of set as the way you update your state.

Your store has two parts: 1. State (todos array) 2. Actions (functions that change the state)

That's it. No reducers. No action types. No dispatch. Just state and functions.

Step 3: Use the Store in a Component

Now let's use this store in a component. Create TodoApp.jsx:

import { useState } from 'react'
import useTodoStore from './todoStore'

function TodoApp() {
  const [input, setInput] = useState('')
  
  // Get what you need from the store
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodoStore()
  
  const handleSubmit = (e) => {
    e.preventDefault()
    if (input.trim()) {
      addTodo(input)
      setInput('')
    }
  }
  
  return (
    

My Todos

setInput(e.target.value)} placeholder="What needs to be done?" />
    {todos.map(todo => (
  • toggleTodo(todo.id)} /> {todo.text}
  • ))}
) } export default TodoApp

Look at that. It just works.

You import the store hook, destructure what you need, and use it. Like any other hook.

No providers. No wrapping. No ceremony.

Step 4: Add Some Polish

Let's add a filter to show all, active, or completed todos.

Update your store:

const useTodoStore = create((set, get) => ({
  todos: [],
  filter: 'all', // new state
  
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, done: false }]
  })),
  
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    )
  })),
  
  deleteTodo: (id) => set((state) => ({
    todos: state.todos.filter(todo => todo.id !== id)
  })),
  
  setFilter: (filter) => set({ filter }), // new action
  
  // Computed value
  getFilteredTodos: () => {
    const { todos, filter } = get()
    if (filter === 'active') return todos.filter(t => !t.done)
    if (filter === 'completed') return todos.filter(t => t.done)
    return todos
  },
}))

See that get parameter? That's how you read the current state inside the store. Perfect for computed values.

Now update the component:

function TodoApp() {
  const [input, setInput] = useState('')
  const { 
    addTodo, 
    toggleTodo, 
    deleteTodo, 
    filter, 
    setFilter,
    getFilteredTodos 
  } = useTodoStore()
  
  const filteredTodos = getFilteredTodos()
  
  // ... rest of your component
  
  return (
    

My Todos

{/* ... form ... */}
    {filteredTodos.map(todo => ( // ... todo items ... ))}
) }

And it works. Filters update, todos update, everything stays in sync.

Understanding Reactivity

Here's something cool. Each component only re-renders when the state it uses changes.

// This component ONLY re-renders when the filter changes
function FilterButtons() {
  const { filter, setFilter } = useTodoStore()
  
  return (
    
) }

If you add a todo, this component doesn't re-render. Because it doesn't use todos.

Zustand is smart about this. You don't have to do anything special.

Bonus: Persist to localStorage

Want to save todos to localStorage? Zustand has a middleware for that:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useTodoStore = create(
  persist(
    (set, get) => ({
      todos: [],
      // ... all your other code
    }),
    {
      name: 'todo-storage', // localStorage key
    }
  )
)

That's it. Now your todos persist across page refreshes.

Seriously. Three lines.

Common Beginner Mistakes (and How to Avoid Them)

Mistake 1: Mutating State Directly

Wrong:

addTodo: (text) => set((state) => {
  state.todos.push({ text }) // Don't do this!
  return { todos: state.todos }
}),

Right:

addTodo: (text) => set((state) => ({
  todos: [...state.todos, { text }] // Create new array
})),

Always return new objects/arrays. Don't mutate.

Mistake 2: Forgetting to Return New State

Wrong:

clearTodos: () => set((state) => {
  console.log('clearing')
  // Forgot to return!
}),

Right:

clearTodos: () => set({ todos: [] }),

The set function needs the new state.

Mistake 3: Using State in Actions Wrong

If you need to read current state in an action, use the function form of set:

// Access current state with the parameter
increment: () => set((state) => ({ count: state.count + 1 })),

Or use get:

increment: () => {
  const currentCount = get().count
  set({ count: currentCount + 1 })
},

TypeScript Version

If you're using TypeScript, here's the same store with types:

import { create } from 'zustand'

interface Todo {
  id: number
  text: string
  done: boolean
}

interface TodoStore {
  todos: Todo[]
  filter: 'all' | 'active' | 'completed'
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  deleteTodo: (id: number) => void
  setFilter: (filter: 'all' | 'active' | 'completed') => void
  getFilteredTodos: () => Todo[]
}

const useTodoStore = create((set, get) => ({
  todos: [],
  filter: 'all',
  
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, done: false }]
  })),
  
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    )
  })),
  
  deleteTodo: (id) => set((state) => ({
    todos: state.todos.filter(todo => todo.id !== id)
  })),
  
  setFilter: (filter) => set({ filter }),
  
  getFilteredTodos: () => {
    const { todos, filter } = get()
    if (filter === 'active') return todos.filter(t => !t.done)
    if (filter === 'completed') return todos.filter(t => t.done)
    return todos
  },
}))

export default useTodoStore

The types make it even nicer to work with.

What You've Learned

In 15 minutes, you built a fully functional todo app with:

  • Global state management
  • Multiple actions
  • Computed values
  • Filtering
  • Optional persistence

And the entire store is like 30 lines of code.

No configuration files. No boilerplate. Just clean, readable code.

That's the Zustand way.

Next Steps

Now that you've built your first store, try:

  • Adding a clearCompleted action
  • Adding due dates to todos
  • Creating separate components that share the same store
  • Building a different app (maybe a shopping cart or theme switcher)

The patterns you learned here apply to any app.

---

Want to level up? Check out my post on Zustand best practices to learn how to structure larger stores and avoid re-renders.
Get More Like This

Want articles like this in your inbox?

Join developers and founders who get practical insights on frontend, SaaS, and building better products.

S

Written by Salman Izhar

Frontend Developer specializing in React, Next.js, and building high-converting web applications.

Learn More