This article is published in English.
Zustand in Practice: Tiny Stores, Sharp Selectors, Plant Tracker
Replace prop-drilling and Redux ceremony with create(), selectors, async actions, persist/devtools middleware, slices, and a hydration plant app.
A beginner-friendly tour of Zustand — the React state library with tens of millions of weekly downloads — plus a plant-care sample built end to end.
The moment someone finally tells you there’s a better way
Before Zustand: what React actually does
Web pages start as HTML tags — div, button, h1. Hand-editing that markup for a large product is painful: every data change means hunting elements and rewriting them. Login flips? Patch a dozen spots. Cart grows? Patch another dozen.
React flips the model. Components are functions that describe UI from current data; the library decides what to patch in the DOM. Authors describe; React updates.
A tiny component looks like this:
function WelcomeBanner() {
return <h1>Hello, stranger</h1>
}
That function returns JSX — HTML-shaped syntax that React compiles, not literal HTML.
Quick glossary for later sections:
- JavaScript — the language behind
console.logandconst x = 5. - npm — the package installer.
npm install zustanddownloads Zustand into the project. - Hooks — functions whose names start with
use(useState,useEffect, custom store hooks). They are the modern React data/effects surface.
If those three ideas landed, the rest of this guide will stick.
React is the chef. You hand it the recipe and ingredients.
What “state” really means
State is anything the UI must remember: logged-in or not, cart size, sidebar open, avatar URL, the text in a search box. Plain variables do not automatically refresh the screen. React hooks hold values and schedule re-renders when those values change.
The simplest hook is useState:
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
// Creates a state variable called count, starting at 0.
// setCount is the only way to change it.
// Every time setCount runs, React re-renders this component.
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
Local state shines when one component owns the value. Trouble starts when distant components must share the same fact — login form, header avatar, dashboard greeting, settings sign-out — without living as neighbors in the tree.
Separate useState calls do not communicate. Sharing across distant UI is the real problem.
Separate states, unable to talk to each other. The problem in one picture.
Prop drilling misery
React’s first answer is “lift state up”: put shared data on the nearest common ancestor and pass it down as props (function arguments).
That works for short trees. It collapses when user must travel App → Layout → MainContent → Dashboard → Header → ProfilePic. Intermediate layers never use the data; they only forward it. Theme, cart, and auth tokens each add another relay race through indifferent parents. Refactors break unrelated files.
Context was meant to stop the relay. A Provider wraps the tree; children call useContext. The catch: consumers often re-render when any part of the context value changes, which hurts for frequently updating state.
Redux (2015) answered with predictable external state and selectors that re-render only what changed — and also introduced a lot of ceremony that teams grew tired of maintaining.
Enter the bear
Zustand comes from Paul Henschel’s Poimandres collective (also behind React Three Fiber and Jotai). The name is German for “state.” A bear mascot stuck. The project sits at tens of thousands of GitHub stars and roughly twenty million weekly npm downloads — more than classic Redux plus Redux Toolkit combined in many recent weeks.
The entire core API is one function, create. Pass a definition of state plus actions; receive a React hook. No Provider. No action-type constants. No separate reducer files. Example:
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
Any component can call that hook. No relay race.
Every component talks directly to the store. No relay race required.
Mental model: the store is a pinned group-chat message everyone can read and edit. Redux feels more like a courthouse — file a motion (action), wait for a reducer judgment, read through a controlled window. Redux bought predictability with bureaucracy; Zustand trusts disciplined updates with less paperwork.
First real store
Scaffold a Vite React app and install the library:
npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev
Create useCounterStore.js:
// useCounterStore.js
import { create } from 'zustand'
// We're borrowing one function from the zustand package.
// That's all we need. Zustand doesn't hide other stuff from us, there genuinely isn't more.
const useCounterStore = create((set) => ({
// create takes a function as its only argument.
// That function receives two tools named set and get.
// We only need set for now. It's how we change state.
count: 0,
// This is a state field. It lives in the store.
// Any component in our app can read it.
increment: () =>
set((state) => ({ count: state.count + 1 })),
// This is an action. Actions are just functions that call set.
// We pass set a function that takes the OLD state and returns
// an object describing what should change.
// Zustand merges this object into the store for us.
decrement: () =>
set((state) => ({ count: state.count - 1 })),
// Another action. Same pattern. Decrement by one.
reset: () => set({ count: 0 }),
// When we don't need the old state, we can just pass a plain object.
// Zustand handles both forms.
}))
export default useCounterStore
// Ship the hook out so other files can import it.
Consume it:
// Counter.jsx
import useCounterStore from './useCounterStore'
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
// Each line is a subscription.
// We grab exactly what we need and no more.
// This matters for performance, we'll get to why.
return (
<div>
<h1>Count {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>reset</button>
</div>
)
}
export default Counter
Mount <Counter /> anywhere. Counts change. Notice what is absent: Provider wrappers, context bootstrap, action constants, reducers, connect, mapDispatchToProps, thunk config. One create call and a hook.
“Wait, that’s actually it?”
Under the hood
create builds a plain object for state and an empty listener list in module scope (a singleton as soon as the module loads).
When a component uses the hook:
- Zustand runs the selector (for example
(state) => state.count) against current state and returns that slice. - It registers the component plus selector as a listener. On later updates it re-runs each selector and re-renders only when the selected value changed.
No React Context in the hot path — closer to a tiny event emitter with React glue. Because the store lives outside the React tree, every import of the same module shares one instance. Vanilla multi-instance stores exist for the rare case that needs them; most apps never need that escape hatch.
The whole lifecycle, four steps and a feedback loop
Selectors (do not skim)
Selectors decide when a component re-renders.
Pattern A — whole store (usually wrong):
const store = useCounterStore()
// No selector. Zustand returns the entire store object.
// Your component now re-renders on ANY state change, even unrelated ones.
// If someone else changes a different piece of state you don't care about,
// this component still wastes a render cycle.
Pattern B — one field (usually right):
const count = useCounterStore((state) => state.count)
// Now your component only re-renders when count changes specifically.
// Other state can update freely. This component sleeps through it.
Pattern C — object literal trap:
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}))
// Looks clean. It's a trap.
// This selector returns a NEW object every time it runs.
// Zustand compares the new object to the old object. They're different objects.
// Result: this component re-renders on every state change in the entire store.
// Worse than pattern A for obvious reasons.
A fresh object every call looks “new” even when fields are unchanged, so re-renders fire constantly.
Bad selector on the left. Good selector on the right. The difference is brutal on big apps.
Need several fields without object churn? Use useShallow:
import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
useShallow((state) => ({
count: state.count,
increment: state.increment,
}))
)
// useShallow tells Zustand "compare the returned object field by field."
// If count and increment didn't change individually, no re-render.
// Now you get clean destructuring AND performance.
Many production codebases prefer multiple narrow hook calls instead. Rule of thumb: pull the smallest slice; when unsure, split hooks rather than merge them.
Actions: sync and async
Actions live on the same object as state. Sync updates use set:
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) =>
set((state) => ({
items: [...state.items, product],
})),
// Spread the existing items into a new array.
// Add the new product at the end.
// Return the updated items array to merge back into state.
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
})),
// Filter out the item with the matching id.
// Return the filtered array.
clear: () => set({ items: [] }),
// Reset to empty. No need to read old state.
}))
get reads current state inside an action without registering a React listener — handy for decisions:
const useWalletStore = create((set, get) => ({
balance: 100,
tryWithdraw: (amount) => {
const currentBalance = get().balance
// Read the current balance at this exact moment.
// No subscription. No re-render. Just a fresh read.
if (currentBalance < amount) {
return { success: false, message: 'Not enough funds' }
// Bail out without touching state.
}
set((state) => ({ balance: state.balance - amount }))
return { success: true, message: 'Withdrawn successfully' }
},
}))
Async work is ordinary async/await — no thunk middleware ceremony:
const usePostStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null })
// Flip loading to true. Clear any previous errors.
// Components showing a spinner will now show it.
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = await response.json()
// Hit the API. Wait for it. Parse the JSON.
set({ posts: data, loading: false })
// Store the posts. Flip loading back to false.
// Components showing the list now have data.
} catch (err) {
set({ error: err.message, loading: false })
// If anything exploded, record the error message.
// Components can now show an error banner.
}
},
}))
That absence of pipeline boilerplate is the practical difference from classic Redux async setups.
Middleware: stacked capabilities
Middleware wraps the store creator. Common pieces follow.
Persist — survive reloads
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{
name: 'theme-storage',
// The key under which Zustand saves your state in localStorage.
// Name it whatever you want, just make it unique.
}
)
)
Theme (or whatever fields you choose) returns after refresh. DevTools → Application → Local Storage shows the JSON blob.
DevTools — inspect actions
Despite the Redux branding, the browser extension works when the store is wrapped with devtools:
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(s) => ({ count: s.count + 1 }),
false,
'counter/increment'
),
// The third argument is the action name shown in DevTools.
// If you skip it, you'll see "anonymous" for every action, which is useless for debugging.
}),
{ name: 'CounterStore' }
)
)
Immer — nested updates without nested spreads
Painful immutable nested spreads:
// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city,
},
},
},
}))
Immer lets updates look mutable while staying immutable underneath:
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: { profile: { address: { city: '' } } },
updateCity: (city) =>
set((state) => {
state.user.profile.address.city = city
// Looks like mutation. Isn't actually mutation.
// Immer tracks the change and produces a new immutable state object.
}),
}))
)
Selector-aware external listeners
For non-React listeners that should fire only when a selected slice changes, Zustand ships subscribeWithSelector middleware:
import { subscribeWithSelector } from 'zustand/middleware'
const useCartStore = create(
subscribeWithSelector((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}))
)
// Somewhere outside React, like in an analytics file:
useCartStore.subscribe(
(state) => state.items,
// The selector. We only care about items.
(items, previousItems) => {
console.log('Cart changed', { from: previousItems, to: items })
// This runs every time items changes, with both old and new values.
// No React component involved. No re-render. Just a side effect.
}
)
Stacking
const useStore = create(
persist(
devtools(
subscribeWithSelector(
immer((set, get) => ({
// your store definition
}))
),
{ name: 'MyStore' }
),
{ name: 'my-store-storage' }
)
)
Ugly once, then forgotten.
The onion of middleware. Each layer adds one capability.
Slices that scale
One file is fine until cart, auth, theme, and notifications collide. Slice factories keep domains separate while composing one store:
// cartSlice.js
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
})
// authSlice.js
export const createAuthSlice = (set, get) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
})
// useAppStore.js
import { create } from 'zustand'
import { createCartSlice } from './cartSlice'
import { createAuthSlice } from './authSlice'
const useAppStore = create((set, get) => ({
...createCartSlice(set, get),
...createAuthSlice(set, get),
}))
Components still select what they need. Past roughly five domains, slices pay rent; before that, one file is clearer.
Testing without mounting UI
Stores are plain modules. Reset, call actions, assert:
// useCounterStore.test.js
import useCounterStore from './useCounterStore'
describe('counter store', () => {
beforeEach(() => {
useCounterStore.setState({ count: 0 })
// Reset state before each test.
// setState is exposed on the hook itself, not just for components.
})
it('increments count', () => {
useCounterStore.getState().increment()
// getState gives you the current store object outside of React.
// .increment() calls the action.
expect(useCounterStore.getState().count).toBe(1)
// Verify the count went from 0 to 1.
})
it('resets count', () => {
useCounterStore.getState().increment()
useCounterStore.getState().increment()
useCounterStore.getState().reset()
expect(useCounterStore.getState().count).toBe(0)
})
})
No renderer, no mock Provider — unit tests stay fast and honest.
Project: Plant Hydration Station
Build a plant-care tracker: add plants with watering frequency, mark watered, highlight overdue, show stats, persist across reloads. Features:
- Add plant (name + days between watering)
- List plants
- Water with one click
- Flag thirsty plants
- Remove plants
- Stats panel
- Persistence across refresh
The entire app on one page. Four components, one store, happy plants.
File 1 — store
src/store/usePlantStore.js:
// src/store/usePlantStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Helper function. Not exported. Just used internally.
// Given a plant object, returns true if it needs water.
const isThirsty = (plant) => {
const msPerDay = 1000 * 60 * 60 * 24
// milliseconds in a second times seconds in a minute
// times minutes in an hour times hours in a day
const daysSinceWatering = (Date.now() - plant.lastWatered) / msPerDay
return daysSinceWatering >= plant.frequencyDays
}
const usePlantStore = create(
persist(
(set, get) => ({
plants: [],
// The big array that holds every plant.
// Each plant will be an object with id, name, frequencyDays, lastWatered.
addPlant: (name, frequencyDays) =>
set((state) => ({
plants: [
...state.plants,
{
id: Date.now() + Math.random(),
// Quick unique id. Good enough for a personal app.
// For production use nanoid or uuid from npm.
name,
frequencyDays,
lastWatered: Date.now(),
// New plants count as freshly watered.
// Otherwise they'd show as thirsty the second they're added, which is mean.
},
],
})),
waterPlant: (id) =>
set((state) => ({
plants: state.plants.map((plant) =>
plant.id === id
? { ...plant, lastWatered: Date.now() }
: plant
),
// Find the matching plant, return a new object with updated timestamp.
// Leave all other plants untouched.
})),
removePlant: (id) =>
set((state) => ({
plants: state.plants.filter((plant) => plant.id !== id),
// Drop the matching plant. Keep everyone else.
})),
// These are getter-style helpers using get().
// They're not stored, they're computed from current state.
thirstyCount: () => get().plants.filter(isThirsty).length,
happyCount: () => get().plants.filter((p) => !isThirsty(p)).length,
isPlantThirsty: (id) => {
const plant = get().plants.find((p) => p.id === id)
return plant ? isThirsty(plant) : false
},
}),
{
name: 'plant-hydration-v1',
// localStorage key. Prefixing with v1 lets me change schema later
// without breaking existing users' data.
}
)
)
export default usePlantStore
File 2 — add form
// src/components/AddPlantForm.jsx
import { useState } from 'react'
import usePlantStore from '../store/usePlantStore'
function AddPlantForm() {
const [name, setName] = useState('')
const [frequency, setFrequency] = useState(3)
// These are local to this component.
// Form inputs are textbook useState territory.
// They don't need to be global.
const addPlant = usePlantStore((state) => state.addPlant)
// Only grab the action we need.
// We don't care about the plants array here, we don't pull it.
const handleSubmit = (event) => {
event.preventDefault()
// Prevent the default form submission that reloads the page.
// Modern React always wants this call on form events.
const trimmed = name.trim()
if (!trimmed) return
// Reject empty or whitespace-only names silently.
addPlant(trimmed, Number(frequency))
// Call the store action.
// Number() converts the string from the input into a number.
setName('')
setFrequency(3)
// Clear the form so the user can add another plant easily.
}
return (
<form onSubmit={handleSubmit} className="add-plant-form">
<h2>Add a Plant</h2>
<label className="field">
<span>Plant name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Monstera, Pothos, Something Latin"
/>
</label>
<label className="field">
<span>Water every</span>
<div className="freq-input">
<input
type="number"
min="1"
max="60"
value={frequency}
onChange={(event) => setFrequency(event.target.value)}
/>
<span>days</span>
</div>
</label>
<button type="submit">Add Plant</button>
</form>
)
}
export default AddPlantForm
File 3 — list
// src/components/PlantList.jsx
import usePlantStore from '../store/usePlantStore'
function PlantList() {
const plants = usePlantStore((state) => state.plants)
const waterPlant = usePlantStore((state) => state.waterPlant)
const removePlant = usePlantStore((state) => state.removePlant)
// Three separate subscriptions.
// Clean. Performant. Obvious.
if (plants.length === 0) {
return (
<div className="empty-state">
<p>No plants yet. Add one to start tracking.</p>
</div>
)
// Empty state so the UI doesn't look broken.
// Always tell the user what they can do next.
}
const daysSinceWatered = (timestamp) => {
const msPerDay = 1000 * 60 * 60 * 24
return Math.floor((Date.now() - timestamp) / msPerDay)
}
return (
<section className="plant-list">
<h2>Your Plants</h2>
<ul>
{plants.map((plant) => {
const days = daysSinceWatered(plant.lastWatered)
const thirsty = days >= plant.frequencyDays
// Compute thirsty status on the fly.
// Cheap calculation. Premature optimization would be storing this.
return (
<li
key={plant.id}
className={thirsty ? 'plant thirsty' : 'plant happy'}
>
<div className="plant-meta">
<strong className="plant-name">{plant.name}</strong>
<span className="plant-when">
{days === 0
? 'Watered today'
: `Last watered ${days} ${days === 1 ? 'day' : 'days'} ago`}
</span>
{thirsty && <span className="badge">THIRSTY</span>}
</div>
<div className="plant-actions">
<button
onClick={() => waterPlant(plant.id)}
className="btn-primary"
>
Water
</button>
<button
onClick={() => removePlant(plant.id)}
className="btn-danger"
>
Remove
</button>
</div>
</li>
)
})}
</ul>
</section>
)
}
export default PlantList
File 4 — stats
// src/components/StatsPanel.jsx
import usePlantStore from '../store/usePlantStore'
function StatsPanel() {
const plants = usePlantStore((state) => state.plants)
// We subscribe to the plants array because our stats depend on it.
// When plants change, this re-renders with fresh totals.
const total = plants.length
const thirstyCount = plants.filter((plant) => {
const days = (Date.now() - plant.lastWatered) / (1000 * 60 * 60 * 24)
return days >= plant.frequencyDays
}).length
const happyCount = total - thirstyCount
return (
<aside className="stats-panel">
<h2>Quick Stats</h2>
<div className="stat-row">
<span>Total plants</span>
<strong>{total}</strong>
</div>
<div className="stat-row">
<span>Needs water</span>
<strong className="danger">{thirstyCount}</strong>
</div>
<div className="stat-row">
<span>Happy plants</span>
<strong className="success">{happyCount}</strong>
</div>
{thirstyCount > 0 && (
<p className="nudge">
{thirstyCount === 1
? 'One plant is waiting on you.'
: `${thirstyCount} plants are waiting on you.`}
</p>
)}
</aside>
)
}
export default StatsPanel
File 5 — App shell
// src/App.jsx
import AddPlantForm from './components/AddPlantForm'
import StatsPanel from './components/StatsPanel'
import PlantList from './components/PlantList'
import './App.css'
function App() {
return (
<div className="app">
<header className="app-header">
<h1>Plant Hydration Station</h1>
<p className="tagline">Don't let them down</p>
</header>
<div className="grid">
<AddPlantForm />
<StatsPanel />
</div>
<PlantList />
</div>
)
// Notice something beautiful here.
// No Provider wrapping anything.
// No props passed to any component.
// Every component reaches into the store on its own.
}
export default App
File 6 — CSS
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #F5F3FF;
color: #2D3436;
}
.app { max-width: 960px; margin: 0 auto; padding: 24px; }
.app-header {
background: linear-gradient(135deg, #6C5CE7, #A29BFE);
color: white;
padding: 24px 28px;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.app-header h1 { margin: 0; font-size: 28px; }
.tagline { margin: 4px 0 0; opacity: 0.9; font-style: italic; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.add-plant-form, .stats-panel, .plant-list {
background: white;
padding: 20px 22px;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.field { display: block; margin-bottom: 14px; }
.field span { display: block; font-size: 13px; color: #636E72; margin-bottom: 6px; }
.field input {
width: 100%;
padding: 10px 12px;
border: 1px solid #DFE6E9;
border-radius: 8px;
font-size: 14px;
}
.freq-input { display: flex; align-items: center; gap: 10px; }
.freq-input input { width: 80px; }
button {
border: none;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
.add-plant-form button[type="submit"] {
background: #6C5CE7;
color: white;
width: 100%;
}
.btn-primary { background: #0984E3; color: white; }
.btn-danger { background: #FFE5E0; color: #E17055; }
.stat-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #F1F2F6; }
.stat-row:last-of-type { border: none; }
.stat-row .danger { color: #E17055; }
.stat-row .success { color: #00B894; }
.nudge { background: #FFF5F0; color: #E17055; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; }
.plant-list ul { list-style: none; padding: 0; margin: 0; }
.plant { display: flex; justify-content: space-between; align-items: center; padding: 14px 6px; border-bottom: 1px solid #F1F2F6; }
.plant:last-child { border: none; }
.plant.thirsty .plant-name::before { content: "🚨 "; }
.plant-meta { display: flex; flex-direction: column; gap: 3px; }
.plant-name { font-size: 15px; }
.plant-when { font-size: 12px; color: #636E72; }
.badge { background: #FFE5E0; color: #E17055; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: bold; display: inline-block; margin-top: 2px; }
.plant-actions { display: flex; gap: 8px; }
.empty-state { text-align: center; padding: 40px 20px; color: #636E72; }
Run npm run dev, add plants, refresh, confirm they remain, water one and watch timestamps reset. Local storage carries the same data into a second tab.
Your finished app. Yours to style however you want from here.
Verify with browser tools
Application / Storage → Local Storage → key plant-hydration-v1 holds the persisted JSON. Persist middleware writes on change and rehydrates before paint. With devtools middleware plus the Redux DevTools extension, each action appears with time-travel for larger stores.
Common mistakes
- Whole-store hook —
useStore()with no selector re-renders on every change. Always select. - Fresh objects from selectors — fix with
useShallowor split hooks. - Storing derived values — compute
thirstyCountfromplants; do not keep a second stale counter. - Missing persist
name— required; omission throws at startup. - One mega-store forever — split when domains multiply.
- Redux ceremony in Zustand — skip action-type enums and giant switch reducers unless a real need appears.
- Forgetting singletons — one
createper module is shared; vanilla APIs exist for per-instance needs. - Skipping store tests —
getState, act, assert; catch regressions early.
When Zustand is the wrong tool
useState— truly local UI (modals, hover, field drafts).- TanStack Query / SWR — server cache, refetch, dedupe, optimistic remote updates. Pair Query for server state with Zustand for client state.
- Context — rare updates (theme tokens) down a subtree.
- Keep Redux — existing Redux codebases, heavy time-travel tooling investment, or genuinely complex flows where the structure already pays for itself. Migrate when savings beat migration cost.
TypeScript sketch
import { create } from 'zustand'
type Plant = {
id: number
name: string
frequencyDays: number
lastWatered: number
}
type PlantState = {
plants: Plant[]
addPlant: (name: string, frequencyDays: number) => void
waterPlant: (id: number) => void
removePlant: (id: number) => void
thirstyCount: () => number
}
const usePlantStore = create<PlantState>((set, get) => ({
plants: [],
addPlant: (name, frequencyDays) =>
set((state) => ({
plants: [
...state.plants,
{ id: Date.now(), name, frequencyDays, lastWatered: Date.now() },
],
})),
waterPlant: (id) =>
set((state) => ({
plants: state.plants.map((p) =>
p.id === id ? { ...p, lastWatered: Date.now() } : p
),
})),
removePlant: (id) =>
set((state) => ({
plants: state.plants.filter((p) => p.id !== id),
})),
thirstyCount: () =>
get().plants.filter((p) => {
const days = (Date.now() - p.lastWatered) / (1000 * 60 * 60 * 24)
return days >= p.frequencyDays
}).length,
}))
One store-shape type plus create<...>; inference covers the rest.
Broader picture
Zustand did not erase Redux’s installed base — rewrites are expensive — but new React apps increasingly default to lighter client stores. Satisfaction surveys in recent State of React rounds put Zustand near the top of “would use again.” A common 2026 stack: Zustand for client state, TanStack Query for server state, occasional Jotai atoms, Context for theme/config, Redux Toolkit only when already present, and plain useState for local UI.
That combination tends to onboard faster than Redux-centric scaffolding and fights the developer less day to day.
Zustand Bear
Production teams still document store conventions (naming, slice boundaries, persist keys) because freedom without norms recreates chaos. The win is that those conventions stay short: selectors are narrow, actions live beside state, middleware is opted in deliberately, and server cache stays out of the client store. Keep that boundary crisp and Zustand remains the ten-line answer to the hundred-line Redux starter kit — without pretending every app is a counter demo forever.
Beyond the plant sample, the same patterns scale to carts, feature flags, wizard drafts, and UI chrome. Start with one store file, add persist when users hate losing work, add DevTools when bugs get subtle, introduce slices when the file scroll bar becomes a joke, and keep Query for anything that round-trips to an API. That progression matches how most successful Zustand codebases actually grow: small, explicit, and allergic to ceremony that does not buy safety.
Digging deeper into selectors and performance
Selector discipline is the difference between a snappy dashboard and a mysteriously sluggish one. Every unnecessary re-render re-runs JSX, effects that depend on props, and child reconciliation. Zustand’s listener model is cheap only when selectors return stable primitives or carefully compared structures.
Prefer selecting booleans, numbers, and strings. When an action function is selected, it is usually stable across updates because it lives on the store object, so pairing count and increment in two hooks is fine. Avoid selecting entire arrays if the component only needs items.length; select the length (or a derived boolean) instead so pushes elsewhere do not wake idle widgets.
Equality matters. Default comparison is Object.is. That is why returning { a, b } from a selector fails: a new object fails Object.is even when a and b are unchanged. useShallow compares one level of fields. For deep structures, either normalize state so UI reads flat fields, or compute a primitive fingerprint intentionally.
Lists deserve special care. Mapping plants in three components is fine if each only needs the array reference when membership or item identity changes. If one panel only shows thirsty names, consider a selector that returns a sorted string of thirsty ids; it stays stable when unrelated plant fields change.
Persist pitfalls and versioning
Persist middleware looks magical until a schema change lands. Always set a stable name for the storage key. When the shape of persisted state changes, bump a version and provide a migrate function so old JSON does not crash the app on load. Partial persistence (partialize) keeps secrets and ephemeral UI flags out of Local Storage — tokens and one-off modal “open” bits rarely belong on disk.
Be aware of hydration timing: the first client render may briefly see defaults before rehydration finishes. For SSR or frameworks that paint on the server, gate UI that depends on persisted values behind a hydrated flag, or accept a flash of default theme. Document the chosen approach so teammates do not “fix” flash bugs that are really hydration races.
Cross-tab behavior also surprises people. Local Storage writes from one tab are visible in others via the storage event, but Zustand’s default persist path does not automatically merge concurrent edits. For collaborative tabs, either accept last-write-wins or add an explicit BroadcastChannel sync layer on top of the store.
Designing actions that stay boring
Good Zustand actions are small, named after user intent, and free of JSX. addPlant, waterPlant, and removePlant beat setPlants dumps from the UI. Keep validation close to the action: reject empty names, clamp watering intervals, ignore unknown ids. Returning early from an action is clearer than letting bad data sit in the store for a render cycle.
Async actions should set explicit loading and error fields when the UI must reflect progress. Fire-and-forget logging can skip those flags. When multiple async calls race, capture a request id or abort controller so an older response cannot overwrite a newer one. None of that requires middleware — just careful set sequencing.
Derived helpers can live as plain functions outside the store (easiest to test) or as getters computed inside selectors. Prefer pure functions imported by both the store and the UI so Jest can assert watering math without React.
Plant app walkthrough notes
When pasting the six files, keep import paths consistent with the Vite template (../store/... from components). If the list looks empty after refresh, check the persist key in DevTools and confirm name: 'plant-hydration-v1' matches what you expect. Watering should update lastWateredAt (or equivalent) so the thirsty selector flips without a full page reload.
Styling in App.css is intentionally plain. Swap fonts and colors freely; the learning target is the data flow, not visual polish. Adding a fourth component — for example a “water all thirsty” button — is a useful exercise: select the thirsty ids, then call an action that maps watering across those ids in one set. That exercise reinforces batching updates instead of looping set from the component.
Team conventions worth writing down
Agree on file layout (stores/ vs colocated feature folders), naming (useXStore), and whether actions may call APIs directly or must go through a Query mutation that then updates Zustand. Many teams ban putting server lists into Zustand entirely, keeping only ephemeral client flags there. Write that rule in the README once; it prevents half the codebase from reinventing a second cache.
Also agree on DevTools naming: the devtools middleware accepts a store name so the extension panel stays readable when five stores are open. Persist keys should be namespaced by app (myapp-theme-v1) to avoid collisions on shared domains.
Comparing emotional weight, not only lines of code
Redux Toolkit shortened classic Redux dramatically, so “100 lines vs 10” is partly rhetorical. The deeper contrast is conceptual surface area: slices vs single create call, reducers vs in-place actions, middleware pipelines vs optional wrappers, Provider trees vs module singletons. Engineers who already think in reducers can be productive in either world. Engineers who want shared client state without a state-machine religion usually finish features faster in Zustand.
None of that excuses skipping reviews. A ten-line store can still encode a security bug if it persists PII without consent, or a performance bug if every component selects the world. Treat the store as a public module API: stable action names, documented persist keys, and tests for the ugly branches (empty lists, invalid ids, failed fetches).
Closing the learning loop
After the plant app works, deliberately break it: remove a selector, return an object literal, persist without a name, store a derived count. Watch the failure modes once so they are recognizable in code review. Then restore the disciplined patterns. That short lab does more for long-term memory than another polished counter demo.
Zustand’s bet is that most client state is boring — flags, drafts, carts, chrome — and boring state deserves a boring API. Keep server truth in a purpose-built cache library, keep local UI in useState, and reserve the bear for the cross-cutting client facts that made prop drilling miserable in the first place. Do that consistently and the “ten lines” claim stops sounding like marketing and starts sounding like the shape of the codebase.