Guía exhaustiva para dominar Zustand, la solución ligera y flexible para manejo de estado en React.
Zustand es una biblioteca de gestión de estado para React que ofrece:
npm install zustand
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
export default useCounterStore;
import useCounterStore from '../store/counterStore';
function Counter() {
const { count, increment, decrement, reset } = useCounterStore();
return (
<div>
<h1>Contador: {count}</h1>
<button onClick={increment}>Incrementar</button>
<button onClick={decrement}>Decrementar</button>
<button onClick={reset}>Resetear</button>
</div>
);
}
export default Counter;
Evita rerenders innecesarios seleccionando solo el estado necesario:
import useCounterStore from '../store/counterStore';
function CountDisplay() {
const count = useCounterStore((state) => state.count);
return <h1>Contador: {count}</h1>;
}
export default CountDisplay;
import { create } from 'zustand';
const useUserStore = create((set) => ({
user: {
id: null,
name: '',
email: '',
preferences: {
theme: 'light',
notifications: true,
},
},
setUser: (userData) => set({ user: { ...userData } }),
updatePreferences: (prefs) =>
set((state) => ({
user: {
...state.user,
preferences: { ...state.user.preferences, ...prefs }
}
})),
clearUser: () => set({
user: { id: null, name: '', email: '', preferences: {} }
}),
}));
export default useUserStore;
import { create } from 'zustand';
const useAuthStore = create((set) => ({
user: null,
loading: false,
error: null,
login: async (credentials) => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
logout: () => {
set({ user: null });
},
}));
export default useAuthStore;
Zustand incluye middlewares útiles:
npm install zustand/middleware
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{
name: 'theme-storage', // clave para localStorage
}
)
);
export default useThemeStore;
const withLogger = (config) => (set, get, api) =>
config(
(...args) => {
console.log(' applying', args);
set(...args);
console.log(' new state', get());
},
get,
api
);
export default withLogger;
import { create } from 'zustand';
import withLogger from './withLogger';
const useCounterStore = create(
withLogger((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);
export default useCounterStore;
import { create } from 'zustand';
const useTodoStore = create((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }]
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
}));
// Store derivado con getters
export const useTodoStats = create((get) => ({
total: () => get(useTodoStore).todos.length,
completed: () => get(useTodoStore).todos.filter(todo => todo.completed).length,
pending: () => get(useTodoStore).todos.filter(todo => !todo.completed).length,
}));
import { create } from 'zustand';
// Slice para autenticación
const createAuthSlice = (set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
});
// Slice para preferencias
const createPreferencesSlice = (set) => ({
theme: 'light',
language: 'es',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
});
// Combinar slices en un store
const useBoundStore = create((...a) => ({
...createAuthSlice(...a),
...createPreferencesSlice(...a),
}));
export default useBoundStore;
import useAuthStore from '../store/authStore';
const unsubscribe = useAuthStore.subscribe(
(state) => state.user,
(user, prevUser) => {
if (user !== prevUser) {
console.log('Usuario cambió:', user);
// Lógica adicional cuando el usuario cambia
}
}
);
// Para dejar de escuchar cambios
// unsubscribe();
import { act } from 'react-test-renderer';
import useCounterStore from '../counterStore';
test('increment increases count by 1', () => {
act(() => {
useCounterStore.getState().increment();
});
expect(useCounterStore.getState().count).toBe(1);
});
test('reset sets count to 0', () => {
// Estado inicial
act(() => {
useCounterStore.getState().increment();
useCounterStore.getState().increment();
});
// Test reset
act(() => {
useCounterStore.getState().reset();
});
expect(useCounterStore.getState().count).toBe(0);
});
import { create } from 'zustand';
import { useQuery } from 'react-query';
const fetchPosts = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
return await res.json();
};
const usePostsStore = create((set) => ({
posts: [],
setPosts: (posts) => set({ posts }),
}));
// Hook personalizado que combina ambos
export const usePosts = () => {
const { posts, setPosts } = usePostsStore();
const query = useQuery('posts', fetchPosts, {
onSuccess: (data) => setPosts(data),
});
return { ...query, posts };
};
npm install immer
import { create } from 'zustand';
import { produce } from 'immer';
const useComplexStore = create((set) => ({
data: {
nested: {
items: [],
config: {
enabled: true,
maxItems: 10,
},
},
},
addItem: (item) =>
set(produce((state) => {
state.data.nested.items.push(item);
})),
toggleEnabled: () =>
set(produce((state) => {
state.data.nested.config.enabled = !state.data.nested.config.enabled;
})),
}));
export default useComplexStore;
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
}
interface AuthState {
user: User | null;
loading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
loading: false,
error: null,
login: async (email, password) => {
set({ loading: true, error: null });
try {
// Lógica de login...
set({ user: { id: '1', name: 'User', email }, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
logout: () => set({ user: null }),
}),
{
name: 'auth-storage',
}
)
);
export default useAuthStore;
Para evitar hidratación inconsistente en SSR:
import { useEffect, useState } from 'react';
import { useStore } from 'zustand';
export const useHydration = () => {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
return hydrated;
};
export function useHydratedStore<T>(store: any, selector: (state: any) => T) {
const state = useStore(store, selector);
const hydrated = useHydration();
return hydrated ? state : selector(store.getState());
}
import useCounterStore from '../store/counterStore';
import { useHydratedStore } from '../store/hydration';
function Counter() {
const count = useHydratedStore(useCounterStore, (state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}