Rendimiento en la gestión de estado — selectores de Zustand, división de context, y estado derivado
Recipe
// Zustand con selectores — los componentes solo re-renderizarán cuando su slice cambie
import { create } from "zustand";
interface AppStore {
user: { name: string; role: string } | null;
cart: CartItem[];
notifications: Notification[];
addToCart: (item: CartItem) => void;
clearNotifications: () => void;
}
const useAppStore = create<AppStore>((set) => ({
user: null,
cart: [],
notifications: [],
addToCart: (item) => set((s) => ({ cart: [...s.cart, item] })),
clearNotifications: () => set({ notifications: [] }),
}));
// BUENO: Selector — solo re-renderiza cuando cart cambia
function CartBadge() {
const cartCount = useAppStore((s) => s.cart.length);
return <span>{cartCount}</span>;
}
// MALO: Sin selector — re-renderiza en CUALQUIER cambio del almacén
function CartBadgeBad() {
const store = useAppStore(); // ¡Se suscribe a todo el almacén!
return <span>{store.cart.length}</span>;
}Cuándo usarlo: Cuando React DevTools muestra componentes re-renderizándose debido a cambios de context o almacén que no están relacionados con lo que muestran. Común en dashboards y aplicaciones con estado global compartido.
Working Example
// ---- ANTES: Suscripción a almacén completo — 47 re-renderizados innecesarios por interacción ----
import { create } from "zustand";
interface DashboardStore {
user: { name: string; avatar: string; role: string };
theme: "light" | "dark";
sidebarOpen: boolean;
notifications: Notification[];
activeTab: string;
searchQuery: string;
filters: Record<string, string>;
cart: CartItem[];
setSidebarOpen: (open: boolean) => void;
setActiveTab: (tab: string) => void;
setSearchQuery: (query: string) => void;
addNotification: (n: Notification) => void;
addToCart: (item: CartItem) => void;
}
const useDashboardStore = create<DashboardStore>((set) => ({
user: { name: "Alice", avatar: "/alice.jpg", role: "admin" },
theme: "light",
sidebarOpen: true,
notifications: [],
activeTab: "overview",
searchQuery: "",
filters: {},
cart: [],
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setActiveTab: (tab) => set({ activeTab: tab }),
setSearchQuery: (query) => set({ searchQuery: query }),
addNotification: (n) => set((s) => ({ notifications: [...s.notifications, n] })),
addToCart: (item) => set((s) => ({ cart: [...s.cart, item] })),
}));
// Cada componente se suscribe al almacén completo
function Header() {
const store = useDashboardStore(); // Re-renderiza al cambiar sidebar, búsqueda, pestaña, etc.
return <header>{store.user.name} ({store.notifications.length})</header>;
}
function Sidebar() {
const store = useDashboardStore(); // Re-renderiza en búsqueda, notificaciones, carrito, etc.
return <nav className={store.sidebarOpen ? "w-64" : "w-16"}>{/* ... */}</nav>;
}
function SearchBar() {
const store = useDashboardStore(); // Re-renderiza en sidebar, notificaciones, carrito, etc.
return (
<input
value={store.searchQuery}
onChange={(e) => store.setSearchQuery(e.target.value)}
/>
);
}
function TabPanel() {
const store = useDashboardStore(); // Re-renderiza en todo
return <div>Active: {store.activeTab}</div>;
}
function CartIcon() {
const store = useDashboardStore(); // Re-renderiza en todo
return <span>Cart: {store.cart.length}</span>;
}
// Escribir en SearchBar re-renderiza todos los 5 componentes = 47 re-renderizados para 10 pulsaciones
// ---- DESPUÉS: Selectores — cada componente se re-renderiza solo para sus propios datos ----
// Misma definición de almacén, pero con selectores
function Header() {
const userName = useDashboardStore((s) => s.user.name);
const notificationCount = useDashboardStore((s) => s.notifications.length);
// Solo re-renderiza cuando user.name o notifications.length cambia
return <header>{userName} ({notificationCount})</header>;
}
function Sidebar() {
const sidebarOpen = useDashboardStore((s) => s.sidebarOpen);
const setSidebarOpen = useDashboardStore((s) => s.setSidebarOpen);
// Solo re-renderiza cuando sidebarOpen cambia
return (
<nav className={sidebarOpen ? "w-64" : "w-16"}>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>Toggle</button>
</nav>
);
}
function SearchBar() {
const searchQuery = useDashboardStore((s) => s.searchQuery);
const setSearchQuery = useDashboardStore((s) => s.setSearchQuery);
// Solo re-renderiza cuando searchQuery cambia
return (
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
);
}
function TabPanel() {
const activeTab = useDashboardStore((s) => s.activeTab);
// Solo re-renderiza cuando activeTab cambia
return <div>Active: {activeTab}</div>;
}
function CartIcon() {
const cartCount = useDashboardStore((s) => s.cart.length);
// Solo re-renderiza cuando cart.length cambia
return <span>Cart: {cartCount}</span>;
}
// Escribir en SearchBar re-renderiza SOLO SearchBar = 10 re-renderizados para 10 pulsaciones
// Reducción: de 47 re-renderizados a 10 (79% menos)Lo que esto demuestra:
- Sin selectores: escribir en SearchBar dispara 5 componentes x 10 pulsaciones = 47 re-renderizados
- Con selectores: solo SearchBar se re-renderiza = 10 re-renderizados (reducción del 79%)
- Cada componente se suscribe exactamente a los datos que necesita
- Las acciones (
setSidebarOpen,setSearchQuery) son referencias estables — no hay necesidad deuseCallback
Deep Dive
Cómo funciona
- Los selectores de Zustand usan
Object.ispara comparar el valor seleccionado entre actualizaciones del almacén. Si el selector devuelve el mismo valor, el componente no se re-renderiza. Esto es fundamentalmente diferente de React Context, que re-renderiza a todos los consumidores en cualquier cambio. - La división de context separa el estado del dispatch en diferentes contexts. Los componentes que solo llaman acciones (dispatch) no se re-renderizarán cuando el estado cambie, porque la referencia de la función de dispatch es estable.
- El estado derivado significa calcular valores a partir del estado existente en lugar de almacenarlos por separado. Almacenar
filteredItemsjunto aitemsyfiltercrea un problema de sincronización y duplica la superficie de actualización de estado. - El estado URL con
useSearchParamsmantiene el estado de filtro y paginación en la URL. Esto es inherentemente no reactivo (sin re-renderizados hasta la navegación), compartible vía URL, y persiste entre actualizaciones de página. useReducerconsolida transiciones de estado relacionadas en una única función de dispatch. Esto previene combinaciones de estado imposibles y reduce el número de setters de estado pasados como props.
Variaciones
Patrón de división de context:
// ANTES: Context único — todos los consumidores se re-renderizarán en cualquier cambio
const AppContext = createContext<{
state: AppState;
dispatch: Dispatch<Action>;
} | null>(null);
// DESPUÉS: Contexts divididos — los consumidores de dispatch nunca se re-renderizarán en cambios de estado
const StateContext = createContext<AppState | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</DispatchContext.Provider>
);
}
// Este componente solo hace dispatch — nunca se re-renderiza en cambios de estado
function AddButton() {
const dispatch = useContext(DispatchContext)!;
return <button onClick={() => dispatch({ type: "ADD" })}>Add</button>;
}
// Este componente solo lee count — se re-renderiza solo cuando count cambia
// (aún se re-renderiza en CUALQUIER cambio de estado — usa useMemo para mayor optimización)
function Counter() {
const state = useContext(StateContext)!;
return <span>{state.count}</span>;
}Estado derivado en lugar de estado almacenado:
// MALO: Almacenar estado derivado — debe mantener filteredProducts sincronizado
const [products, setProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState("all");
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
useEffect(() => {
setFilteredProducts(products.filter((p) => filter === "all" || p.category === filter));
}, [products, filter]);
// BUENO: Calcular en el renderizado — siempre sincronizado, sin effect necesario
const [products, setProducts] = useState<Product[]>([]);
const [filter, setFilter] = useState("all");
const filteredProducts = useMemo(
() => products.filter((p) => filter === "all" || p.category === filter),
[products, filter]
);Estado URL para filtros y paginación:
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function ProductFilters() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = searchParams.get("category") || "all";
const sort = searchParams.get("sort") || "name";
const page = parseInt(searchParams.get("page") || "1");
function updateParam(key: string, value: string) {
const params = new URLSearchParams(searchParams);
params.set(key, value);
params.set("page", "1"); // Reinicia página al cambiar filtro
router.push(`${pathname}?${params.toString()}`);
}
return (
<div className="flex gap-4">
<select value={category} onChange={(e) => updateParam("category", e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
<select value={sort} onChange={(e) => updateParam("sort", e.target.value)}>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</div>
);
}TypeScript Notes
- Los selectores de Zustand están totalmente tipados:
useStore((s) => s.field)infiere el tipo de retorno de la interfaz del almacén. - Para selectores complejos, usa
useShallowdesdezustand/react/shallowpara comparar selecciones de objetos:useStore(useShallow((s) => ({ a: s.a, b: s.b }))). useReducerinfiere tipos de acción de la función reductora. Usa una unión discriminada para el tipo de acción.
Gotchas
-
Seleccionar objetos sin
useShallow—useStore((s) => ({ a: s.a, b: s.b }))crea un nuevo objeto en cada actualización del almacén, derrotando la optimización del selector. Fix: UsauseShallowdesdezustand/react/shallowpara comparar propiedades de objetos superficialmente. -
Selectores inline que crean nuevas referencias —
useStore((s) => s.items.filter(...))crea un nuevo array en cada actualización del almacén, incluso si el resultado del filtro es idéntico. Fix: O memoiza el selector fuera del componente o usauseMemodentro del componente. -
Context con demasiados valores — Un único context proporcionando
{ user, theme, locale, cart, notifications }re-renderiza a todos los consumidores cuando cualquier valor cambia. Fix: Divide en contextos separados por dominio, o cambia a Zustand con selectores. -
Almacenar estado derivado — Mantener
filteredItemsen estado junto aitemsyfilterrequiere sincronización manual y causa bugs de doble renderizado. Fix: Calcula estado derivado conuseMemoen cada renderizado. Siempre está sincronizado y a menudo es más barato que la alternativa basada en efectos. -
Usar useState para estado complejo — Múltiples llamadas
useStaterelacionadas (loading,error,data) pueden desincronizarse. Fix: UsauseReducerpara estado que tiene múltiples sub-valores o transiciones complejas. -
Prop drilling vs rendimiento de context — El prop drilling no causa re-renderizados extra (solo el componente objetivo se re-renderiza). Context causa re-renderizados en todos los consumidores. Fix: Si solo hay 2-3 niveles de profundidad, prop drilling es más performante que context.
Alternativas
| Enfoque | Compensación |
|---|---|
| Zustand con selectores | Suscripciones granulares; dependencia externa |
| React Context dividido | Sin dependencia; setup verboso, aún re-renderiza a todos los consumidores de estado |
| Jotai | Modelo de estado atómico; modelo mental diferente de Zustand |
| Valtio | Basado en proxy; rastreo automático; menos explícito |
| Estado URL | Compartible, persistente; solo para estado de filtro/paginación serializable |
| React Compiler | Auto-optimiza consumidores de context; experimental |
| Prop drilling | Cero overhead; impracticable para componentes profundamente anidados |
FAQs
¿Por qué los selectores de Zustand previenen re-renderizados innecesarios?
- Los selectores usan
Object.ispara comparar el valor seleccionado entre actualizaciones del almacén. - Si el selector devuelve el mismo valor, el componente se salta el re-renderizado.
- Sin un selector, el componente se suscribe a todo el almacén y se re-renderiza en cualquier cambio.
¿Cuál es el problema con useStore() sin argumento de selector?
// MALO: re-renderiza en CUALQUIER cambio del almacén (sidebar, búsqueda, carrito, etc.)
const store = useAppStore();
return <span>{store.cart.length}</span>;
// BUENO: re-renderiza solo cuando cart.length cambia
const cartCount = useAppStore((s) => s.cart.length);
return <span>{cartCount}</span>;¿Cómo previene la división de context re-renderizados en componentes de solo-dispatch?
- Divide estado y dispatch en contextos separados.
- Los componentes que solo llaman acciones usan el context de dispatch, que tiene una referencia estable.
- Nunca se re-renderizarán cuando el estado cambie porque no están suscritos al context de estado.
¿Por qué calcular estado derivado con useMemo es mejor que almacenarlo en estado?
- El estado derivado almacenado (
filteredProductsjunto aproductsyfilter) requiere sincronización manual víauseEffect. - Esto causa bugs de doble renderizado y superficie de actualización de estado extra.
useMemocalcula en el renderizado -- siempre sincronizado, sin effect necesario.
¿Cuándo deberías usar estado URL en lugar de estado de componente para filtros?
- Cuando estado de filtro/paginación debe ser compartible vía URL.
- Cuando estado debe persistir entre actualizaciones de página.
- El estado URL es inherentemente no reactivo (sin re-renderizados hasta la navegación), reduciendo actualizaciones innecesarias.
Gotcha: ¿Por qué seleccionar un objeto desde Zustand sin useShallow derrota la optimización?
useStore((s) => ({ a: s.a, b: s.b }))crea una nueva referencia de objeto en cada actualización del almacén.Object.isve una referencia diferente cada vez e dispara un re-renderizado.- Fix: Usa
useShallowdesdezustand/react/shallowpara selecciones de objetos.
import { useShallow } from "zustand/react/shallow";
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));¿Por qué el prop drilling es a veces más performante que React Context?
- El prop drilling solo re-renderiza el componente objetivo que recibe el prop cambiado.
- Context re-renderiza a TODOS los consumidores cuando cualquier valor en el context cambia.
- Si solo hay 2-3 niveles de profundidad, prop drilling evita el problema de re-renderizado de broadcast.
Gotcha: ¿Por qué usar múltiples llamadas useState para estado relacionado causa bugs?
- Múltiples llamadas
useStaterelacionadas (loading,error,data) pueden desincronizarse durante actualizaciones. - Establecer
errory olvidar limpiarloadingproduce estados imposibles. - Fix: Usa
useReducerpara estado con múltiples sub-valores o transiciones complejas.
¿Cómo se tipan los selectores de Zustand en TypeScript?
useStore((s) => s.field)infiere el tipo de retorno de la interfaz del almacén automáticamente.- Para selecciones multi-campo, usa
useShallowque preserva el tipo de objeto inferido.
interface AppStore { cart: CartItem[]; user: User | null; }
// cartCount se infiere como number
const cartCount = useAppStore((s) => s.cart.length);¿Cómo tipas una acción useReducer como una unión discriminada en TypeScript?
type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_DATA"; payload: Product[] }
| { type: "SET_ERROR"; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING": return { ...state, loading: action.payload };
case "SET_DATA": return { ...state, data: action.payload, loading: false };
case "SET_ERROR": return { ...state, error: action.payload, loading: false };
}
}¿Cómo funciona useSearchParams para estado basado en URL en Next.js?
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
function Filters() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const category = searchParams.get("category") || "all";
function updateFilter(key: string, value: string) {
const params = new URLSearchParams(searchParams);
params.set(key, value);
router.push(`${pathname}?${params.toString()}`);
}
}¿Cuándo deberías elegir Zustand sobre React Context para gestión de estado?
- Cuando múltiples componentes no relacionados se suscriben a diferentes slices del mismo estado.
- Cuando necesitas suscripciones granulares sin re-renderizar a todos los consumidores.
- Context es fine para estado simple y raramente cambiante (tema, locale); Zustand es mejor para estado frecuentemente actualizado (búsqueda, filtros, carrito).
Related
- Preventing Re-renders — Patrones estructurales para reducir re-renderizados
- Memoization — useMemo para cálculo de estado derivado
- Data Fetching Performance — Estado del servidor vs estado del cliente
- Performance Checklist — Elementos de auditoría de gestión de estado