본문으로 건너뛰기
Previous
Next
Zustand Complete Guide: Simple State Management for React...

Zustand Complete Guide: Simple State Management for React with TypeScript

Zustand Complete Guide: Simple State Management for React with TypeScript

이 글의 핵심

Zustand separates vanilla stores from React bindings and minimizes re-renders with selector-based subscriptions. Learn immer, persist, SSR, Next.js App Router store patterns, and common pitfalls.

Key Takeaways

Zustand is a minimal state management library for React. No boilerplate, TypeScript-first, with middleware support for persistence, devtools, and immutability. This guide covers core concepts, advanced patterns, and production best practices.

Real-world experience: Migrating from Redux to Zustand reduced boilerplate by 90% and bundle size by 50% in production applications.

Why Zustand?

Scenario 1: Too Much Boilerplate

Redux requires actions, reducers, and dispatch. Zustand is just a hook:

const count = useStore((state) => state.count);

Scenario 2: Bundle Size Matters

Redux + Redux Toolkit: ~20KB. Zustand: 1KB minified.

Scenario 3: TypeScript Complexity

Redux requires manual type definitions for actions and reducers. Zustand infers types automatically from the store.


1. What is Zustand?

Core Features

Zustand is a small, fast, and scalable state management solution for React.

Key Advantages:

  • Simple API: No boilerplate
  • Tiny Size: 1KB minified
  • TypeScript: Full type inference
  • Middleware: Persist, Devtools, Immer
  • Vanilla: Use outside React

2. Basic Usage

Installation

npm install zustand

Creating a Store

// store/useStore.ts
import { create } from 'zustand';

interface Store {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useStore = create<Store>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

Using in Components

// components/Counter.tsx
import { useStore } from '../store/useStore';

export default function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  const decrement = useStore((state) => state.decrement);

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

3. Advanced Patterns

Async Actions

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

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

Computed Values

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  total: () => {
    const { items } = get();
    return items.reduce((sum, item) => sum + item.price, 0);
  },
}));

4. Middleware

Persist Middleware

Persist store to localStorage automatically:

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

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

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

Devtools Middleware

Debug with Redux DevTools:

import { devtools } from 'zustand/middleware';

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

Immer Middleware

Mutate state immutably:

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

export const useStore = create<Store>()(
  immer((set) => ({
    nested: { deep: { value: 0 } },
    updateDeep: (value: number) =>
      set((state) => {
        state.nested.deep.value = value;
      }),
  }))
);

5. Slice Pattern

Split large stores into smaller slices:

// store/slices/userSlice.ts
export const createUserSlice = (set, get) => ({
  users: [],
  fetchUsers: async () => {
    const users = await api.getUsers();
    set({ users });
  },
});

// store/slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});

// store/index.ts
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';

export const useStore = create((set, get) => ({
  ...createUserSlice(set, get),
  ...createCartSlice(set, get),
}));

6. Selector Optimization

Anti-Pattern

// Subscribes to entire store (unnecessary re-renders)
const store = useStore();

Best Practice

// Subscribe only to needed values
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

Shallow Comparison

import { shallow } from 'zustand/shallow';

const { count, increment } = useStore(
  (state) => ({ count: state.count, increment: state.increment }),
  shallow
);

7. Vanilla Store (Without React)

Use Zustand outside React:

import { createStore } from 'zustand/vanilla';

const store = createStore<Store>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

// Subscribe
const unsubscribe = store.subscribe((state) => {
  console.log('Count:', state.count);
});

// Use
store.getState().increment();
console.log(store.getState().count); // 1

// Unsubscribe
unsubscribe();

Advanced Topics

Store and Re-renders

useStore(selector) tracks selector output equality to re-render only changed components. If selector returns new objects every time (e.g., { a, b }), reference changes trigger unnecessary re-renders. Use shallow comparison or split fields into multiple useStore calls.

Middleware Chain

devtools, persist, and immer wrap the store and affect type inference and storage format based on order. Document a standard order for your team. Partial storage and migration versions in persist are essential for long-term production use.

Next.js App Router

To share global stores across Server Components and Client Components, import store modules only inside client trees. Pass SSR initial state via props or fetch results, then seed the store after hydration on the client. This pattern is safer.

Troubleshooting

SymptomCheck
State correct but UI doesn’t updateSelector returns reference-equal object
Infinite loopset called inside subscribe callback
Tests share stateCreate new store per test or reset with setState

Interviews & Career

State management and global store design are frequently asked in frontend interviews. For technical interview preparation, see the Tech Interview Complete Guide. To write quantified stories like “Redux ??Zustand migration” on your resume, check Developer Resume & Interview Guide.


Summary & Checklist

Key Points

  • Zustand: Simple state management
  • Tiny: 1KB bundle
  • TypeScript: Full support
  • Middleware: Persist, Devtools, Immer
  • Selectors: Optimize re-renders
  • Vanilla: Use outside React

Implementation Checklist

  • Install Zustand
  • Create store
  • Connect components
  • Implement async actions
  • Add middleware
  • Optimize selectors
  • Apply slice pattern

  • [React 18 Deep Dive](/en/blog/react-18-deep-dive/
  • [Next.js 15 Complete Guide](/en/blog/nextjs-15-complete-guide/
  • [React Native Complete Guide](/en/blog/react-native-complete-guide/

Keywords

Zustand, State Management, React, TypeScript, Redux, Frontend, Performance

Frequently Asked Questions (FAQ)

Q. How does Zustand compare to Redux?

A. Zustand is much simpler and lighter. Redux offers more features but with higher complexity. For most applications, Zustand’s simplicity wins.

Q. How about Context API?

A. Zustand has better performance and easier usage. Context API is suitable for avoiding prop drilling but causes unnecessary re-renders without proper optimization.

Q. Is it production-ready?

A. Yes, many companies use Zustand in production. It’s stable and well-maintained by the Poimandres team.

Q. Does it support SSR?

A. Yes, works with Next.js. Create stores on the client side and hydrate from server-provided initial data.

Q. Can I use it with React Native?

A. Absolutely. Zustand works the same in React Native. Use persist middleware with AsyncStorage instead of localStorage.

Q. What about testing?

A. Easy to test. Reset store state before each test or create fresh stores per test suite. No mocking required for most scenarios.


Production Operations

Key Implementation Patterns

When deploying Zustand stores to production, consider:

  1. Input Contracts: Define clear TypeScript interfaces for store state and actions
  2. Monitoring: Track state changes and action calls with middleware or logging
  3. Error Boundaries: Wrap components using stores to catch and recover from state errors
  4. Performance: Profile selector performance and re-render counts in production
  5. Migration: Plan for store schema changes with persist middleware versioning

Troubleshooting in Production

SymptomPossible CauseSolution
Intermittent failuresRace conditions, stale closuresUse functional updates with set((state) => ...)
Memory leaksUnsubscribed listeners, uncleaned timersAlways unsubscribe, use cleanup in useEffect
State desyncMultiple store instances, SSR hydration mismatchSingleton pattern, client-only hydration
Slow updatesN+1 re-renders, heavy selectorsMemoize selectors, use shallow comparison

Recommended Order: (1) Reproduce with minimal example (2) Narrow recent changes (3) Check environment differences (4) Validate with metrics (5) Test after fix.

For deployment: git add ??git commit ??git push ??npm run deploy.