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
{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
clearCompletedaction - 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.Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Frontend Developer specializing in React, Next.js, and building high-converting web applications.
Learn More