Skip to content

State Management with Zustand: A Modern Approach

Learn how to use Zustand for lightweight, performant state management in React applications without the boilerplate of Redux.

By 6 min read
  • React
  • Zustand
  • State Management
  • TypeScript
  • Frontend
State Management with Zustand: A Modern Approach

Zustand is a small, fast, and scalable state management solution for React. After using it extensively in production applications like Unique Sports Group, I've found it to be the perfect balance between simplicity and power. It eliminates Redux boilerplate while providing excellent TypeScript support and performance.

Why Zustand?

Compared to Redux, Zustand offers:

  • Less boilerplate: No actions, reducers, or action creators
  • Smaller bundle: ~1KB vs Redux's ~10KB+
  • Better TypeScript support: Simpler types, less complexity
  • No Provider needed: Works without wrapping your app
  • Better performance: Only re-renders components using specific state

Basic Setup

Install Zustand:

Shell
npm install zustand

Create a simple store:

TSX
import { create } from 'zustand';

interface BearState {
  bears: number;
  increase: (by: number) => void;
  reset: () => void;
}

const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
  reset: () => set({ bears: 0 }),
}));

// Use in component
function BearCounter() {
  const bears = useBearStore((state) => state.bears);
  const increase = useBearStore((state) => state.increase);

  return (
    <div>
      <h1>{bears} bears around here...</h1>
      <button onClick={() => increase(1)}>Add bear</button>
    </div>
  );
}

Selective Subscriptions

One of Zustand's best features is selective subscriptions. Components only re-render when the specific state they use changes:

TSX
// This component only re-renders when 'bears' changes
function BearCounter() {
  const bears = useBearStore((state) => state.bears);
  return <div>{bears}</div>;
}

// This component only re-renders when 'fish' changes
function FishCounter() {
  const fish = useBearStore((state) => state.fish);
  return <div>{fish}</div>;
}

Complex State Management

Async Actions

Handle async operations easily:

TSX
interface User {
  id: string;
  name: string;
}

interface UserState {
  users: User[];
  loading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
}

const useUserStore = create<UserState>((set) => ({
  users: [],
  loading: false,
  error: null,
  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('/api/users');
      const users: User[] = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: (error as Error).message, loading: false });
    }
  },
}));

Computed Values

Derive computed values using selectors:

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

interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
}

const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, done: false }]
  })),
}));

// Computed selector
function TodoStats() {
  const totalTodos = useTodoStore((state) => state.todos.length);
  const completedTodos = useTodoStore((state) => 
    state.todos.filter(todo => todo.done).length
  );

  return (
    <div>
      <p>Total: {totalTodos}</p>
      <p>Completed: {completedTodos}</p>
    </div>
  );
}

Middleware

Zustand supports middleware for common patterns:

Persist Middleware

Persist state to localStorage:

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

interface User {
  id: string;
  name: string;
}

interface AuthState {
  user: User | null;
  token: string | null;
  login: (user: User, token: string) => void;
  logout: () => void;
}

const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      login: (user, token) => set({ user, token }),
      logout: () => set({ user: null, token: null }),
    }),
    {
      name: 'auth-storage', // localStorage key
    }
  )
);

Immer Middleware

Use Immer for immutable updates:

TSX
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

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

interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
}

const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    addTodo: (text) => set((state) => {
      state.todos.push({ id: Date.now(), text, done: false });
    }),
    toggleTodo: (id) => set((state) => {
      const todo = state.todos.find(t => t.id === id);
      if (todo) todo.done = !todo.done;
    }),
  }))
);

DevTools Middleware

Connect to Redux DevTools:

TSX
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface StoreState {
  count: number;
  increment: () => void;
}

const useStore = create<StoreState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    { name: 'MyStore' }
  )
);

Splitting Stores

For larger applications, split stores by domain:

TSX
// stores/authStore.ts
interface User {
  id: string;
  name: string;
}

interface AuthState {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
}));

// stores/todoStore.ts
interface Todo {
  id: number;
  text: string;
}

interface TodoState {
  todos: Todo[];
  addTodo: (todo: Todo) => void;
}

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
}));

Testing Zustand Stores

Testing Zustand stores is straightforward:

TSX
import { renderHook, act } from '@testing-library/react';
import { useBearStore } from './bearStore';

test('increases bears', () => {
  const { result } = renderHook(() => useBearStore());
  
  act(() => {
    result.current.increase(1);
  });

  expect(result.current.bears).toBe(1);
});

Best Practices

  1. Use selectors: Only subscribe to the state you need
  2. Split stores: Organize by domain, not by component
  3. Type everything: Leverage TypeScript for type safety
  4. Use middleware: Persist, Immer, and DevTools when needed
  5. Keep stores focused: Each store should have a single responsibility
  6. Avoid over-engineering: Zustand is simple, keep it that way

Real-World Example

Here's a complete example from a production application:

TSX
interface Player {
  id: string;
  name: string;
}

interface FilterState {
  search: string;
  position: string;
}

interface PlayerState {
  players: Player[];
  filters: FilterState;
  selectedPlayer: Player | null;
  setFilters: (filters: FilterState) => void;
  setSelectedPlayer: (player: Player | null) => void;
  fetchPlayers: () => Promise<void>;
}

export const usePlayerStore = create<PlayerState>((set, get) => ({
  players: [],
  filters: { search: '', position: 'all' },
  selectedPlayer: null,
  
  setFilters: (filters) => set({ filters }),
  
  setSelectedPlayer: (player) => set({ selectedPlayer: player }),
  
  fetchPlayers: async () => {
    const { filters } = get();
    const players = await api.getPlayers(filters);
    set({ players });
  },
}));

// Usage in component
function PlayerList() {
  const players = usePlayerStore((state) => state.players);
  const fetchPlayers = usePlayerStore((state) => state.fetchPlayers);
  
  useEffect(() => {
    fetchPlayers();
  }, [fetchPlayers]);
  
  return (
    <div>
      {players.map(player => (
        <PlayerCard key={player.id} player={player} />
      ))}
    </div>
  );
}

Performance Benefits

In production applications, Zustand has provided:

  • 40-60% reduction in re-renders compared to Context API
  • Smaller bundle size compared to Redux
  • Simpler codebase with less boilerplate
  • Better TypeScript experience
  • Easier testing and debugging

Zustand strikes the perfect balance between simplicity and power. It's become my go-to state management solution for React applications, especially when you need something more than local state but less than Redux.

Keep reading