DevToolBoxGRATIS
Blog

Guía Zustand 2026: Gestión de Estado Ligera para React

17 min de lecturapor DevToolBox Team
TL;DR

Zustand is a minimal, unopinionated state management library for React that uses a hook-based API with no providers or boilerplate. Create a store with create(), consume it with a hook, and enjoy automatic re-render optimization through selectors. It supports middleware like persist, devtools, and immer out of the box, works great with TypeScript, and handles SSR in Next.js gracefully. At under 1KB gzipped, Zustand is the go-to choice when Redux feels too heavy and Context re-renders too much.

What Is Zustand and Why Use It?

Zustand (German for "state") is a small, fast, and scalable state management library for React. Created by the team behind Jotai and React Spring, Zustand provides a simple hook-based API that eliminates the need for providers, reducers, or action creators. It stores state outside of React, which means updates only trigger re-renders in components that actually use the changed data.

npm install zustand
# or
yarn add zustand
# or
pnpm add zustand

Zustand vs Redux vs Jotai vs Recoil vs Context

Each state management solution has trade-offs. Here is how Zustand compares to the alternatives.

FeatureZustandRedux ToolkitJotaiRecoilReact Context
BoilerplateMinimalModerateMinimalMediumLow
Provider neededNoYesYesYesYes
Bundle size~1KB~11KB~3KB~14KB0KB (built-in)
DevToolsMiddlewareBuilt-inThird partyExtensionNone
TypeScriptExcellentGoodExcellentGoodExcellent
SSR supportNativeNeeds setupNativeLimitedNative

Creating Stores with create()

A Zustand store is created by calling create() with a function that receives set and get. The returned value is a React hook that components use to access the store.

Basic Store

The simplest store holds state and actions together. The set function merges partial state by default, similar to React setState.

import { create } from 'zustand';

// Create a counter store
const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

// Use in a component — no Provider needed!
function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

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

Accessing State Outside React

Zustand stores can be accessed outside of React components. The hook itself has getState() and setState() methods for imperative access.

// Access state outside React
const currentCount = useCounterStore.getState().count;

// Update state imperatively
useCounterStore.setState({ count: 10 });

// Subscribe to all changes
const unsub = useCounterStore.subscribe((state) => {
  console.log('Count changed:', state.count);
});

// Unsubscribe when done
unsub();

Selectors and Preventing Re-renders

By default, calling useStore() subscribes to the entire store and re-renders on every change. Use selectors to pick only the state slices your component needs.

Basic Selectors

Pass a selector function to the hook to extract specific state. The component only re-renders when the selected value changes (using Object.is comparison).

const useTodoStore = create((set) => ({
  todos: [],
  filter: 'all',
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, { id: Date.now(), text, done: false }],
  })),
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map((t) =>
      t.id === id ? { ...t, done: !t.done } : t
    ),
  })),
  setFilter: (filter) => set({ filter }),
}));

// Only re-renders when todos array changes
function TodoList() {
  const todos = useTodoStore((state) => state.todos);
  return todos.map((todo) => <div key={todo.id}>{todo.text}</div>);
}

// Only re-renders when filter changes
function FilterBar() {
  const filter = useTodoStore((state) => state.filter);
  const setFilter = useTodoStore((state) => state.setFilter);
  return <select value={filter} onChange={(e) => setFilter(e.target.value)} />;
}

Shallow Equality for Object Selectors

When selecting multiple values, use shallow from zustand/shallow to compare by shallow equality instead of referential equality. This prevents unnecessary re-renders when the object shape is the same.

import { useShallow } from 'zustand/shallow';

// BAD: creates a new object every render -> infinite re-renders
// const { name, email } = useUserStore((s) => ({ name: s.name, email: s.email }));

// GOOD: useShallow compares each property individually
function UserProfile() {
  const { name, email } = useUserStore(
    useShallow((state) => ({ name: state.name, email: state.email }))
  );
  return <div>{name} ({email})</div>;
}

// Also works with arrays
function UserActions() {
  const [updateName, updateEmail] = useUserStore(
    useShallow((s) => [s.updateName, s.updateEmail])
  );
  // ...
}

Actions and Async Actions

Actions in Zustand are just functions inside the store that call set(). There is no dispatch, no action types, no reducers. Async actions are equally straightforward.

Synchronous Actions

Define actions alongside state. The set function accepts a partial state object or an updater function that receives the current state.

const useCartStore = create((set, get) => ({
  items: [],
  totalPrice: 0,

  addItem: (product) => set((state) => {
    const existing = state.items.find((i) => i.id === product.id);
    if (existing) {
      return {
        items: state.items.map((i) =>
          i.id === product.id ? { ...i, qty: i.qty + 1 } : i
        ),
      };
    }
    return { items: [...state.items, { ...product, qty: 1 }] };
  }),

  removeItem: (id) => set((state) => ({
    items: state.items.filter((i) => i.id !== id),
  })),

  // Use get() to read current state inside an action
  calculateTotal: () => {
    const { items } = get();
    const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
    set({ totalPrice: total });
  },
}));

Asynchronous Actions

Async actions are regular async functions. Call set() when the data is ready. You can track loading and error states inside the store.

const usePostStore = create((set) => ({
  posts: [],
  loading: false,
  error: null,

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

  createPost: async (data) => {
    const res = await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    const newPost = await res.json();
    set((state) => ({ posts: [newPost, ...state.posts] }));
  },
}));

Middleware: persist, devtools, immer, subscribeWithSelector

Zustand supports middleware that wraps the store creator to add functionality. Middleware composes by nesting.

persist — Automatic Local Storage

The persist middleware saves state to localStorage (or any storage) and rehydrates it on page load. You can configure which parts of the state to persist.

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

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      fontSize: 16,
      language: 'en',
      setTheme: (theme) => set({ theme }),
      setFontSize: (fontSize) => set({ fontSize }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'app-settings', // localStorage key
      storage: createJSONStorage(() => localStorage),
      // Only persist these fields
      partialize: (state) => ({
        theme: state.theme,
        fontSize: state.fontSize,
        language: state.language,
      }),
    }
  )
);

devtools — Redux DevTools Integration

The devtools middleware connects your store to Redux DevTools for time-travel debugging and action inspection.

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

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set(
        (state) => ({ count: state.count + 1 }),
        false,        // replace: false (merge)
        'increment'   // action name in DevTools
      ),
    }),
    { name: 'MyAppStore' } // DevTools instance name
  )
);

immer — Immutable Updates Made Easy

The immer middleware lets you write mutable-looking code that produces immutable updates. This is especially helpful for deeply nested state.

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

const useNestedStore = create(
  immer((set) => ({
    user: {
      profile: {
        name: 'Alice',
        address: { city: 'NYC', zip: '10001' },
      },
      settings: { notifications: true },
    },

    // Without immer you would need:
    // set((s) => ({ user: { ...s.user, profile: { ...s.user.profile,
    //   address: { ...s.user.profile.address, city: newCity } } } }))

    // With immer, just mutate the draft:
    updateCity: (city) => set((state) => {
      state.user.profile.address.city = city;
    }),

    toggleNotifications: () => set((state) => {
      state.user.settings.notifications =
        !state.user.settings.notifications;
    }),
  }))
);

// Combine middleware: immer + devtools + persist
const useAppStore = create(
  devtools(
    persist(
      immer((set) => ({
        // your state and actions
      })),
      { name: 'app-storage' }
    ),
    { name: 'AppStore' }
  )
);

subscribeWithSelector — Fine-Grained Subscriptions

The subscribeWithSelector middleware enables subscribing to specific state slices outside of React. This is useful for side effects, logging, or syncing with external systems.

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

const useStore = create(
  subscribeWithSelector((set) => ({
    count: 0,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }))
);

// Subscribe to a specific slice
const unsub = useStore.subscribe(
  (state) => state.count,        // selector
  (count, prevCount) => {         // listener
    console.log('Count:', prevCount, '->', count);
    if (count >= 10) {
      console.log('Reached 10!');
    }
  },
  { fireImmediately: true }       // options
);

TypeScript Integration and Typed Stores

Zustand has first-class TypeScript support. Define an interface for your state and pass it as a generic to create().

Fully Typed Store

Define a State interface that includes both state and actions. Pass it as a generic parameter to create for complete type safety.

interface AuthState {
  user: { id: string; name: string; email: string } | null;
  token: string | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  updateProfile: (data: Partial<{ name: string; email: string }>) => void;
}

const useAuthStore = create<AuthState>()((set) => ({
  user: null,
  token: null,
  isAuthenticated: false,

  login: async (email, password) => {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    const { user, token } = await res.json();
    set({ user, token, isAuthenticated: true });
  },

  logout: () => set({
    user: null, token: null, isAuthenticated: false,
  }),

  updateProfile: (data) => set((state) => ({
    user: state.user ? { ...state.user, ...data } : null,
  })),
}));

Typed Selectors

Selectors automatically infer return types. You can also type selector functions explicitly for complex transformations.

// Type is inferred automatically
function NavBar() {
  // userName: string | undefined (inferred)
  const userName = useAuthStore((s) => s.user?.name);
  // isAuth: boolean (inferred)
  const isAuth = useAuthStore((s) => s.isAuthenticated);

  return isAuth ? <span>Hello, {userName}</span> : <LoginButton />;
}

// Explicit selector type for complex derivations
const selectUserDisplay = (state: AuthState): string => {
  if (!state.user) return 'Guest';
  return state.user.name + ' (' + state.user.email + ')';
};

function UserBadge() {
  const display = useAuthStore(selectUserDisplay);
  return <span>{display}</span>;
}

Slices Pattern for Large Stores

For large applications, split your store into slices. Each slice manages a subset of state and can access other slices through the full store.

Creating and Combining Slices

Each slice is a function that receives set and get and returns a partial state object. Combine slices into a single create() call.

// types.ts
interface UserSlice {
  user: { name: string } | null;
  setUser: (user: { name: string }) => void;
}

interface CartSlice {
  items: Array<{ id: string; name: string; qty: number }>;
  addItem: (item: { id: string; name: string }) => void;
  clearCart: () => void;
}

type AppState = UserSlice & CartSlice;

// slices/userSlice.ts
const createUserSlice = (set: any): UserSlice => ({
  user: null,
  setUser: (user) => set({ user }),
});

// slices/cartSlice.ts
const createCartSlice = (set: any, get: any): CartSlice => ({
  items: [],
  addItem: (item) => set((state: AppState) => ({
    items: [...state.items, { ...item, qty: 1 }],
  })),
  // Access user slice from cart slice via get()
  clearCart: () => {
    const user = get().user;
    console.log('Clearing cart for:', user?.name);
    set({ items: [] });
  },
});

// store.ts — combine slices
import { create } from 'zustand';

const useAppStore = create<AppState>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

Using Zustand with Next.js (SSR Hydration)

Zustand works with Next.js but requires special handling for server-side rendering. The key challenge is avoiding shared state between requests on the server.

SSR-Safe Store Setup

Create a store factory that produces a new store per request on the server. Use a React context provider to pass the store instance to components.

// store.ts
import { createStore } from 'zustand';

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

// Factory: creates a NEW store instance each call
export const createAppStore = (initialCount = 0) => {
  return createStore<AppState>()((set) => ({
    count: initialCount,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }));
};

export type AppStore = ReturnType<typeof createAppStore>;

// provider.tsx
'use client';
import { createContext, useContext, useRef } from 'react';
import { useStore } from 'zustand';

const StoreContext = createContext<AppStore | null>(null);

export function StoreProvider({
  children,
  initialCount,
}: {
  children: React.ReactNode;
  initialCount: number;
}) {
  const storeRef = useRef<AppStore>(null);
  if (!storeRef.current) {
    storeRef.current = createAppStore(initialCount);
  }
  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  );
}

// Custom hook for accessing the store
export function useAppStore<T>(selector: (state: AppState) => T): T {
  const store = useContext(StoreContext);
  if (!store) throw new Error('Missing StoreProvider');
  return useStore(store, selector);
}

Testing Zustand Stores

Zustand stores are plain JavaScript objects, making them easy to test without React rendering. You can test stores in isolation or with components.

Unit Testing a Store

Test store logic by calling actions and checking state directly. No React rendering needed.

// counterStore.test.ts
import { useCounterStore } from './counterStore';

describe('counterStore', () => {
  // Reset store before each test
  beforeEach(() => {
    useCounterStore.setState({ count: 0 });
  });

  it('increments count', () => {
    useCounterStore.getState().increment();
    expect(useCounterStore.getState().count).toBe(1);
  });

  it('decrements count', () => {
    useCounterStore.setState({ count: 5 });
    useCounterStore.getState().decrement();
    expect(useCounterStore.getState().count).toBe(4);
  });

  it('resets count', () => {
    useCounterStore.setState({ count: 99 });
    useCounterStore.getState().reset();
    expect(useCounterStore.getState().count).toBe(0);
  });
});

Testing Components with Stores

When testing components that use Zustand stores, reset the store before each test to avoid state leaking between tests.

// Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { useCounterStore } from './counterStore';
import { Counter } from './Counter';

// Reset store before each test
const initialState = useCounterStore.getState();
beforeEach(() => {
  useCounterStore.setState(initialState, true);
});

it('displays the count and increments on click', () => {
  render(<Counter />);
  expect(screen.getByText('0')).toBeInTheDocument();
  fireEvent.click(screen.getByText('+'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

Zustand vs React Context

React Context is built-in and simple, but it has a critical performance problem: any update to the context value re-renders every consumer. Zustand solves this by storing state outside React and using selectors for granular subscriptions.

The React Context Problem

With Context, changing a single value re-renders every component that consumes the context, even if that component does not use the changed value. Zustand components only re-render when their selected slice of state changes.

// PROBLEM: React Context re-renders all consumers
const AppContext = React.createContext(null);

function App() {
  const [state, setState] = useState({
    theme: 'dark',
    user: 'Alice',
    count: 0,
  });
  return (
    <AppContext.Provider value={{ state, setState }}>
      {/* ALL children re-render when ANY value changes */}
      <ThemeDisplay />  {/* re-renders on count change */}
      <UserDisplay />   {/* re-renders on count change */}
      <Counter />       {/* re-renders on theme change */}
    </AppContext.Provider>
  );
}

// SOLUTION: Zustand with selectors
const useAppStore = create((set) => ({
  theme: 'dark',
  user: 'Alice',
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

// Only re-renders when theme changes
function ThemeDisplay() {
  const theme = useAppStore((s) => s.theme);
  return <div>Theme: {theme}</div>;
}

// Only re-renders when count changes
function Counter() {
  const count = useAppStore((s) => s.count);
  const increment = useAppStore((s) => s.increment);
  return <button onClick={increment}>{count}</button>;
}

Performance Optimization

Zustand is fast by default, but there are patterns to squeeze out maximum performance in large applications.

Atomic Selectors

Select the smallest unit of state possible. Avoid selecting entire objects when you only need one property.

// BAD: selects the entire user object
// Re-renders when ANY user property changes
function Avatar() {
  const user = useStore((s) => s.user);
  return <img src={user.avatar} />;
}

// GOOD: selects only what is needed
// Only re-renders when avatar URL changes
function Avatar() {
  const avatar = useStore((s) => s.user.avatar);
  return <img src={avatar} />;
}

Transient Updates (No Re-render)

Use subscribe() for updates that should not trigger re-renders, like animations or frequent data streams.

// Transient updates: update DOM directly without re-rendering
import { useEffect, useRef } from 'react';

const useMouseStore = create((set) => ({
  x: 0,
  y: 0,
  setPosition: (x, y) => set({ x, y }),
}));

// This component NEVER re-renders from mouse moves
function Cursor() {
  const ref = useRef(null);

  useEffect(() => {
    // Subscribe to store changes and update DOM directly
    const unsub = useMouseStore.subscribe((state) => {
      if (ref.current) {
        ref.current.style.transform =
          'translate(' + state.x + 'px, ' + state.y + 'px)';
      }
    });
    return unsub;
  }, []);

  return <div ref={ref} style={{ width: 20, height: 20 }} />;
}

Best Practices and Common Patterns

  • Keep stores small and focused on a single domain — prefer multiple stores over one mega store
  • Always use selectors to prevent unnecessary re-renders — never call useStore() without arguments
  • Colocate actions with the state they modify inside the store
  • Use the immer middleware for deeply nested state updates
  • Reset stores in test setup to prevent state leaking between tests
  • Use persist middleware with partialize to only save essential data to storage
  • Prefer useShallow from zustand/shallow when selecting multiple values
  • For SSR, create store instances per request and pass them via context

Frequently Asked Questions

Is Zustand better than Redux?

Zustand is not universally better but is a strong choice for most applications. Redux Toolkit remains a good option for very large teams that benefit from strict conventions, middleware ecosystem, and established patterns. Zustand excels when you want minimal boilerplate, smaller bundle size, and a simpler mental model. For new projects in 2026, Zustand is often the recommended starting point.

Does Zustand need a Provider component?

No. Zustand stores exist outside of the React tree by default, so no Provider is needed. This is one of its biggest advantages — you can use the store hook anywhere without wrapping your app. The only exception is SSR scenarios where you need per-request store instances, in which case you create a custom provider.

Can Zustand replace React Context for global state?

Yes, and it is recommended for most cases. React Context re-renders all consumers when any value changes, while Zustand uses selectors to re-render only the components that use the changed data. For theme toggles or locale, Context is fine. For frequently changing data (form state, real-time data, UI state), Zustand is significantly more performant.

How does Zustand handle TypeScript?

Zustand has excellent TypeScript support. You define a State interface and pass it as a generic to create(). All selectors, actions, and middleware are fully typed. The create() function infers types from the initial state when no generic is provided, though explicit typing is recommended for complex stores.

Can Zustand persist state to localStorage?

Yes, using the built-in persist middleware. It automatically saves state to localStorage and rehydrates it on page load. You can customize the storage engine (sessionStorage, AsyncStorage for React Native, or any custom storage), choose which state slices to persist with partialize, and configure migration functions for schema changes.

How do I use Zustand with Next.js SSR?

Create a store factory function that returns a new store instance. On the server, each request gets its own store to prevent shared state between users. Pass the store via a React context provider and use a custom hook to access it. Zustand provides a createStore function (without the React hook wrapper) specifically for this pattern.

How does Zustand compare to Jotai?

Both are created by the same team (Poimandres). Zustand uses a top-down approach with stores, while Jotai uses a bottom-up atomic approach similar to Recoil. Choose Zustand when you have well-defined state shapes and want module-level access. Choose Jotai when you prefer atomic, composable pieces of state that are defined close to their consumers.

Is Zustand production-ready?

Absolutely. Zustand is used in production by thousands of companies and has over 50,000 GitHub stars. It is actively maintained, well-documented, and has a stable API. Major companies use it for everything from small dashboards to large-scale applications.

Key Takeaways
  • Zustand provides the simplest API for React state management — create a store, use a hook, done
  • Selectors are the key to performance — always select only what you need
  • Built-in middleware (persist, devtools, immer) covers the most common needs without extra packages
  • TypeScript support is first-class with full type inference for stores, selectors, and middleware
  • At under 1KB gzipped, Zustand adds virtually no overhead to your bundle
  • The slices pattern scales to large applications while keeping code organized
𝕏 Twitterin LinkedIn
¿Fue útil?

Mantente actualizado

Recibe consejos de desarrollo y nuevas herramientas.

Sin spam. Cancela cuando quieras.

Prueba estas herramientas relacionadas

{ }JSON FormatterTSJSON to TypeScript

Artículos relacionados

Guía de Patrones React: Compound Components, Custom Hooks, HOC, Render Props y State Machines

Guía completa de patrones React: compound components, custom hooks, HOC, render props, provider pattern, state machines y error boundaries.

Patrones React Query 2026: Fetching, Cache y Mutations con TanStack Query

Domina los patrones React Query (TanStack Query) 2026: useQuery, useMutation, actualizaciones optimistas.

Guía Avanzada de Next.js: App Router, Server Components, Data Fetching, Middleware y Rendimiento

Guía completa avanzada de Next.js: App Router, Server Components, streaming SSR, data fetching, middleware, caché y despliegue.