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​