State Management with Zustand: A Lightweight Alternative to Redux

State Management with Zustand: A Lightweight Alternative to Redux

I migrated a production dashboard from Redux to Zustand and the entire state management layer went from 1,200 lines to 340. Here's everything I learned about Zustand -- the good, the surprising, and the gotchas.

I have a confession: I used to be a Redux maximalist. Every new React project got Redux Toolkit, createSlice for everything, thunks for async, the whole ceremony. It worked fine, but "fine" is a low bar when you're writing 60 lines of boilerplate for a counter.

Then a colleague showed me Zustand on a side project. I was skeptical -- how could something this small handle a real application? So I tried migrating our internal dashboard (40+ pieces of state, 12 async actions, websocket integration) from Redux Toolkit to Zustand. The migration took two days. The total code reduction was about 70%. And the app was actually faster because Zustand's selective subscriptions meant fewer unnecessary re-renders. I was sold.

Why Zustand Exists

Zustand (German for "state") weighs about 1KB gzipped. It was created by the team behind Jotai and React Spring. The core idea is simple: a store is a hook. You call the hook in your component, it subscribes to changes, and your component re-renders when the state it uses changes. No Provider wrappers, no context, no reducers, no dispatch.

That's not just less code -- it's less conceptual overhead. With Redux, you need to understand actions, action creators, reducers, the store, dispatch, selectors, middleware, and thunks before you can do anything useful. With Zustand, you create a store and use it. That's it.

I think this matters more than people admit. I've onboarded junior developers who took a full week to internalize the Redux data flow. With Zustand, they're productive in an hour. The mental model is "here's an object with state and functions that change it." That's JavaScript 101.

Creating Your First Store

import { create } from 'zustand';

interface TodoStore {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  setFilter: (filter: TodoStore['filter']) => void;
}

const useTodoStore = create<TodoStore>((set) => ({
  todos: [],
  filter: 'all',

  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, completed: false }],
  })),

  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map((t) =>
      t.id === id ? { ...t, completed: !t.completed } : t
    ),
  })),

  setFilter: (filter) => set({ filter }),
}));

State and actions live together in the same object. No separate action types, no switch statements, no action creators. The set function does a shallow merge by default -- set({ filter }) updates only the filter, leaving todos untouched.

One thing that tripped me up early on: set only merges at the top level. If you have nested state like { user: { name: 'Dan', settings: { theme: 'dark' } } } and you call set({ user: { name: 'Dan' } }), you've just wiped out the settings object. You need to spread the nested object yourself, or use the immer middleware which I'll cover later.

Using it in components:

function TodoList() {
  const todos = useTodoStore((state) => state.todos);
  const toggleTodo = useTodoStore((state) => state.toggleTodo);

  return todos.map((todo) => (
    <div key={todo.id} onClick={() => toggleTodo(todo.id)}>
      {todo.text}
    </div>
  ));
}

Notice there's no useDispatch, no action object, no string constants. You call toggleTodo(id) directly. Actions are just functions. This is one of those things that seems trivially different until you've worked in a large codebase where tracing a Redux action from the dispatch call through the middleware to the reducer to the state change involves opening five different files.

Selectors and Performance

This is where Zustand gets interesting. Each call to the store hook with a selector only re-renders when that specific piece of state changes:

// Only re-renders when filter changes
const filter = useTodoStore((state) => state.filter);

// Only re-renders when todos change
const todos = useTodoStore((state) => state.todos);

By default, Zustand uses strict equality (===) to compare the old and new selected values. This is fast but has a gotcha: if your selector returns a new object or array every time, the component re-renders every time even if nothing actually changed.

// BAD: creates a new array every render, triggers re-render every time
const activeTodos = useTodoStore(
  (state) => state.todos.filter((t) => !t.completed)
);

// GOOD: use shallow comparison for derived data
import { useShallow } from 'zustand/react/shallow';

const activeTodos = useTodoStore(
  useShallow((state) => state.todos.filter((t) => !t.completed))
);

useShallow does a shallow comparison of the result, so it only re-renders when the actual contents change. Use it whenever your selector returns objects or arrays.

I actually hit this exact bug in production once. We had a component that selected a filtered list of notifications, and it was re-rendering on every single state change in the entire store. The React profiler showed it re-rendering 200+ times during a normal user session. Adding useShallow to the selector dropped it to about 15 re-renders. The performance improvement was visible -- the notification panel stopped lagging when other parts of the app were busy.

There's another pattern I use for expensive derived state: memoizing inside the store itself.

const useStore = create((set, get) => ({
  items: [],
  searchQuery: '',
  
  setSearchQuery: (query) => set({ searchQuery: query }),
  
  // Computed value accessed via get() outside React
  getFilteredItems: () => {
    const { items, searchQuery } = get();
    if (!searchQuery) return items;
    return items.filter(item => 
      item.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
  },
}));

This keeps the filtering logic in the store rather than spreading it across components. For truly expensive computations, you can add memoization with a library like memoize-one or just use useMemo in the component.

Middleware: Persist, Devtools, and Immer

Zustand's middleware system is composable and powerful:

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

const useStore = create<MyStore>()(
  devtools(
    persist(
      immer((set) => ({
        todos: [],
        addTodo: (text) => set((state) => {
          // With immer, you can mutate directly
          state.todos.push({ id: Date.now(), text, completed: false });
        }),
      })),
      { name: 'todo-storage' }
    )
  )
);

persist saves state to localStorage and rehydrates on load. Use partialize to only persist specific fields -- don't persist UI state like modal visibility or loading flags. I made this mistake once and every page load showed a stale loading spinner because isLoading: true was persisted from an interrupted request.

persist(
  (set) => ({ /* ... */ }),
  {
    name: 'my-app-storage',
    partialize: (state) => ({
      todos: state.todos,
      userPreferences: state.userPreferences,
      // Deliberately NOT persisting: isLoading, error, modalState
    }),
  }
)

devtools connects to Redux DevTools for time-travel debugging. Yes, Redux DevTools works with Zustand. Yes, it's ironic. Name your actions so they show up clearly in the devtools panel:

addTodo: (text) => set(
  (state) => ({ todos: [...state.todos, { id: Date.now(), text, completed: false }] }),
  false,
  'addTodo'  // This name appears in Redux DevTools
),

immer lets you write mutations that look imperative but produce immutable updates. state.todos.push() instead of [...state.todos, newTodo]. Useful for deeply nested state. But I'd say only reach for immer when you actually have deep nesting. For flat state, the spread syntax is clearer and doesn't add a dependency.

Async Actions

No thunks, no sagas, no middleware needed. Just use async/await directly:

const useUserStore = create<UserStore>((set, get) => ({
  users: [],
  isLoading: false,
  error: null,

  fetchUsers: async () => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch('/api/users');
      const users = await res.json();
      set({ users, isLoading: false });
    } catch (err) {
      set({ error: err.message, isLoading: false });
    }
  },

  // Access current state with get()
  refreshCurrentUser: async () => {
    const { users } = get();
    const currentUser = users.find((u) => u.isActive);
    if (currentUser) {
      const res = await fetch(\`/api/users/\${currentUser.id}\`);
      const updated = await res.json();
      set((state) => ({
        users: state.users.map((u) => (u.id === updated.id ? updated : u)),
      }));
    }
  },
}));

The get function gives you access to the current state inside actions. No need for getState() or useSelector() gymnastics.

One pattern I've found useful for async actions is an abort controller for cleanup. If a user navigates away before a fetch completes, you don't want to update state for a component that's no longer mounted:

const useStore = create((set, get) => ({
  abortController: null,
  
  fetchData: async () => {
    // Cancel any in-flight request
    get().abortController?.abort();
    const controller = new AbortController();
    set({ abortController: controller, isLoading: true });
    
    try {
      const res = await fetch('/api/data', { signal: controller.signal });
      const data = await res.json();
      set({ data, isLoading: false, abortController: null });
    } catch (err) {
      if (err.name !== 'AbortError') {
        set({ error: err.message, isLoading: false, abortController: null });
      }
    }
  },
}));

The Slices Pattern for Large Apps

For larger applications, split your store into slices:

const createUserSlice = (set, get) => ({
  users: [],
  fetchUsers: async () => { /* ... */ },
});

const createPostSlice = (set, get) => ({
  posts: [],
  fetchPosts: async () => { /* ... */ },
});

const useStore = create((...a) => ({
  ...createUserSlice(...a),
  ...createPostSlice(...a),
}));

Each slice is a plain function that receives set and get. The slices get merged into one store. Unlike Redux where slices are completely isolated, Zustand slices can access each other's state through get(). That's a double-edged sword. It's convenient when your post slice needs to check the current user. It's dangerous when slices become tightly coupled and you can't reason about state changes without reading five different files -- which is exactly the problem you left Redux to avoid.

My rule: a slice can read from other slices via get(), but it should only write to its own state. If an action needs to modify state across multiple slices, create a separate "orchestrator" action at the root level.

Zustand vs Redux vs Context

My rule of thumb after using all three extensively:

  • React Context -- use for truly static values (theme, locale, auth token). Context re-renders all consumers on any change, making it terrible for frequently-changing state.
  • Zustand -- use for anything that changes at runtime. Application state, UI state, server cache. 90% of what people use Redux for.
  • Redux Toolkit -- use when you need time-travel debugging, strict middleware pipelines, or your team already knows Redux deeply. It's more powerful but more ceremonial.

The size difference is stark: Zustand is ~1KB, Redux Toolkit is ~40KB. For most applications, Zustand gives you everything you need at a fraction of the bundle cost.

There's also the question of when to use Zustand versus a server-state library like TanStack Query (React Query). If your state comes from an API and follows the fetch-cache-invalidate pattern, TanStack Query is a better fit. It handles caching, refetching, stale data, and optimistic updates. Use Zustand for client-side state -- UI toggles, form state, user preferences, shopping carts -- things that don't have a server-side source of truth.

TypeScript Integration

Zustand's TypeScript support is excellent but has one quirk -- the curried form for middleware:

// Without middleware: type parameter on create
const useStore = create<MyStore>((set) => ({ ... }));

// With middleware: empty parentheses after create
const useStore = create<MyStore>()(
  devtools(persist((set) => ({ ... }), { name: 'storage' }))
);

That () after create<MyStore> is required when using middleware. It's a TypeScript workaround for inferring middleware types correctly. The first time I saw this syntax, I thought it was a typo. It's not -- without it, TypeScript can't properly infer the types through the middleware chain. The Zustand docs explain the technical reason, but honestly you just memorize "middleware means extra parentheses" and move on.

Testing Zustand Stores

Testing is straightforward because stores are just functions:

import { act } from '@testing-library/react';
import { useTodoStore } from './store';

beforeEach(() => {
  useTodoStore.setState({ todos: [], filter: 'all' });
});

test('adds a todo', () => {
  act(() => {
    useTodoStore.getState().addTodo('Test todo');
  });

  expect(useTodoStore.getState().todos).toHaveLength(1);
  expect(useTodoStore.getState().todos[0].text).toBe('Test todo');
});

Use setState to reset state between tests and getState to access state outside React. No mock stores, no providers, no ceremony.

One thing that bit me in testing: Zustand stores are singletons by default. If you don't reset state in beforeEach, test pollution will cause mysterious failures where a test passes in isolation but fails when run with other tests. The setState call above handles this, but you need to remember to include every piece of state in the reset, including state from all slices if you're using the slices pattern.

For component tests where you need to render with specific initial state, you can set state before rendering:

test('shows empty message when no todos', () => {
  useTodoStore.setState({ todos: [], filter: 'all' });
  
  render(<TodoList />);
  
  expect(screen.getByText('No todos yet')).toBeInTheDocument();
});

test('shows todos when they exist', () => {
  useTodoStore.setState({ 
    todos: [{ id: 1, text: 'Buy milk', completed: false }], 
    filter: 'all' 
  });
  
  render(<TodoList />);
  
  expect(screen.getByText('Buy milk')).toBeInTheDocument();
});

This is so much simpler than wrapping components in a Redux provider with a configured store. The test reads like plain JavaScript because it is plain JavaScript.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!