Skip to main content

Zustand Counter: Complete Step-by-Step

A counter is the "Hello, World" of state management. It is simple enough to focus on library mechanics but realistic enough to show re-render behavior, persistence, and component integration. This walkthrough builds a counter with Zustand, starting from the store definition and ending with a working React component.

I built my first Zustand counter in 2023 and noticed how much faster I was moving compared to Redux. No boilerplate, no action types, no middleware setup — just a store with methods.

Step 1: Install Zustand

Zustand is available on npm. Install it in your React project:

npm install zustand

Zustand has no dependencies and works with any React version 16.8+. The entire library is a single small file; no peer dependencies or transitive installs.

Step 2: Define the Store

Create a file counterStore.ts (or .js if not using TypeScript) and define the store using create():

import { create } from 'zustand';

export const useCounterStore = create((set) => ({
count: 0,

increment: () => set((state) => ({ count: state.count + 1 })),

decrement: () => set((state) => ({ count: state.count - 1 })),

incrementByAmount: (amount) => set((state) => ({
count: state.count + amount,
})),

reset: () => set({ count: 0 }),
}));

The set function merges the object you pass into the store state. Use the function form set((state) => ...) when you need to compute the new value from the old state (like incrementing). For simple assignments, pass an object directly: set({ count: 0 }).

Step 3: Use the Store in Components

Call useCounterStore like any React hook to extract the state and methods you need:

import { useCounterStore } from './counterStore';

export 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);

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

The selector (state) => state.count ensures this component re-renders only when count changes. If another component uses useCounterStore for a different field, this one will not re-render when that field updates.

Step 4: Add Multiple Components

Create a second component that reads the same state:

export function CounterDisplay() {
const count = useCounterStore((state) => state.count);

return <p>The current count is: {count}</p>;
}

export function IncrementBy5Button() {
const incrementByAmount = useCounterStore((state) => state.incrementByAmount);

return <button onClick={() => incrementByAmount(5)}>+5</button>;
}

Both components see the same store and update in sync. When one calls increment(), the other's count selector automatically triggers a re-render.

Step 5: Add Persistence

Zustand ships with a persist middleware that syncs state to localStorage automatically:

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

export const useCounterStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}),
{
name: 'counter-storage', // localStorage key
}
)
);

Now, when the page reloads, the counter restores its previous value. The persist middleware is a higher-order function that wraps your store definition and handles the localStorage sync.

Key Takeaways

  • Use create((set) => ({ ... })) to define a Zustand store. The set function updates state by merging an object into it.
  • Extract specific fields with selectors: useStore((state) => state.field). Only components using that field re-render when it changes.
  • For methods, define them in the store and call them directly: const increment = useStore((state) => state.increment).
  • Zustand persist middleware syncs state to localStorage with zero additional code.
  • Stores are global singletons, so all components using useCounterStore share the same state.

Frequently Asked Questions

Why do I need the selector function (state) => state.count?

The selector lets Zustand compare the returned value between renders. If the selector returns the same value, the component does not re-render, even if other parts of the store changed. Without selectors, every state change would trigger every component using the store.

Can I call multiple selectors in one hook call?

You can call multiple separate hooks, but it is inefficient. Instead, extract multiple values in one call using object destructuring: const { count, increment } = useCounterStore((state) => ({ count: state.count, increment: state.increment })). However, this defeats re-render optimization because the returned object is recreated every time. For best performance, use multiple hook calls or install the shallow selector from Zustand docs.

How do I reset the store to its initial state?

Define a reset() method like in the example, or call useCounterStore.setState({ count: 0 }) directly. The second approach works outside React components too.

Does persist work with SSR (Next.js)?

The persist middleware works, but you must hydrate the store on the client to avoid hydration mismatches. Zustand provides a hydrate() method and a custom hook pattern for SSR. See the Zustand SSR guide for details.

Further Reading