Selectores y rendimiento
Receta
Usa funciones selector con useStore(selector) para suscribirte a slices específicos del state. Los componentes solo se re-renderizan cuando cambia el valor seleccionado, evitando renderizados innecesarios.
"use client";
import { useCartStore } from "@/stores/cart-store";
function CartBadge() {
// Solo re-renderiza cuando cambia el array items
const itemCount = useCartStore((state) => state.items.length);
return <span className="badge">{itemCount}</span>;
}
function CartTotal() {
// Solo re-renderiza cuando cambia el total
const total = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
return <span>${total.toFixed(2)}</span>;
}Ejemplo funcional
// stores/dashboard-store.ts
import { create } from "zustand";
interface DashboardStore {
user: { name: string; avatar: string };
notifications: { id: string; message: string }[];
theme: "light" | "dark";
sidebarOpen: boolean;
toggleSidebar: () => void;
toggleTheme: () => void;
addNotification: (message: string) => void;
}
export const useDashboardStore = create<DashboardStore>((set) => ({
user: { name: "Jane", avatar: "/avatar.png" },
notifications: [],
theme: "light",
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
addNotification: (message) =>
set((s) => ({
notifications: [...s.notifications, { id: crypto.randomUUID(), message }],
})),
}));// components/header.tsx
"use client";
import { useDashboardStore } from "@/stores/dashboard-store";
import { useShallow } from "zustand/react/shallow";
// Malo: re-renderiza ante CUALQUIER cambio del store
function HeaderBad() {
const store = useDashboardStore();
return <div>{store.user.name}</div>;
}
// Bueno: solo re-renderiza cuando cambia user.name
function HeaderGood() {
const userName = useDashboardStore((s) => s.user.name);
return <div>{userName}</div>;
}
// Bueno: varios valores con comparación superficial
function HeaderWithMultiple() {
const { name, avatar } = useDashboardStore(
useShallow((s) => ({ name: s.user.name, avatar: s.user.avatar }))
);
return (
<div>
<img src={avatar} alt={name} />
<span>{name}</span>
</div>
);
}
// Bueno: seleccionar varios primitivos con array y useShallow
function NotificationBar() {
const [notifications, theme] = useDashboardStore(
useShallow((s) => [s.notifications, s.theme])
);
return (
<div className={theme}>
{notifications.length} notificaciones
</div>
);
}Análisis profundo
Cómo funciona
- Zustand usa igualdad estricta (
===) por defecto para comparar el valor seleccionado anterior y el siguiente. - Si el selector devuelve un primitivo (string, number, boolean), solo re-renderiza cuando el valor cambia realmente.
- Si el selector devuelve un objeto o array nuevo en cada llamada, el componente se re-renderiza en cada cambio de state porque
{} !== {}. useShallowdezustand/react/shallowrealiza una comparación superficial sobre el objeto o array seleccionado, evitando re-renderizados cuando las propiedades individuales no han cambiado.- Los selectores se ejecutan de forma síncrona durante el renderizado. Manténlos rápidos y sin efectos secundarios.
Variaciones
Auto-generación de selectores:
import { create } from "zustand";
import { StoreApi, UseBoundStore } from "zustand";
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
function createSelectors<S extends UseBoundStore<StoreApi<object>>>(store: S) {
const storeIn = store as WithSelectors<typeof store>;
storeIn.use = {} as any;
for (const key of Object.keys(storeIn.getState())) {
(storeIn.use as any)[key] = () => storeIn((s: any) => s[key]);
}
return storeIn;
}
// Uso
const useCounterStoreBase = create<CounterState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
export const useCounterStore = createSelectors(useCounterStoreBase);
// Selectores auto-generados
const count = useCounterStore.use.count();
const increment = useCounterStore.use.increment();Selector derivado memorizado:
import { useMemo } from "react";
function ExpensiveList() {
const items = useCartStore((s) => s.items);
// Memorizar cálculo costoso
const sortedItems = useMemo(
() => [...items].sort((a, b) => b.price - a.price),
[items]
);
return <ul>{sortedItems.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}Seleccionar acciones (referencia estable):
// Las acciones nunca cambian, así que este selector nunca provoca un re-renderizado
const increment = useCounterStore((s) => s.increment);
const reset = useCounterStore((s) => s.reset);Notas de TypeScript
- Los selectores están completamente tipados. El tipo de retorno del selector determina el tipo del valor.
useShallowpreserva el tipo de retorno del selector.
// el resultado está tipado como number
const count = useStore((s: StoreState) => s.count);
// el resultado está tipado como { name: string; email: string }
const user = useStore(
useShallow((s: StoreState) => ({ name: s.user.name, email: s.user.email }))
);Errores comunes
- Devolver un objeto nuevo desde un selector sin
useShallowprovoca re-renderizados en cada cambio de state:(s) => ({ a: s.a, b: s.b })crea un objeto nuevo cada vez. useShallowsolo compara un nivel de profundidad. Los objetos o arrays anidados siguen usando igualdad por referencia para sus hijos.- Seleccionar una función de acción como
(s) => s.incrementnunca provoca re-renderizados porque las referencias de las funciones son estables en Zustand. Esto es seguro y recomendado. - No llames a los selectores de forma condicional. Internamente son hooks y deben seguir las reglas de los hooks de React.
- Evita cálculos costosos dentro de los selectores. Usa
useMemodespués de seleccionar los datos sin procesar.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Selectores individuales | Precisos, re-renderizados mínimos | Verboso para muchos valores |
| useShallow | Selección de varios valores, API simple | Solo comparación superficial |
| Selectores auto-generados | Cero boilerplate por acceso a campo | Magia, más difícil de depurar |
| Sin selector (store completo) | Simple | Re-renderiza en cada cambio |
FAQs
¿Qué comprobación de igualdad usa Zustand por defecto para los selectores?
- Zustand usa igualdad estricta (
===) para comparar el valor seleccionado anterior y el siguiente. - Los primitivos (string, number, boolean) solo provocan re-renderizados cuando el valor cambia realmente.
- Los objetos y arrays provocan re-renderizados cada vez porque
{} !== {}.
¿Cuándo debes usar useShallow y qué hace?
- Usa
useShallowcuando tu selector devuelve un objeto o array con varios valores. - Realiza una comparación superficial sobre el objeto/array devuelto, evitando re-renderizados cuando las propiedades individuales no han cambiado.
- Impórtalo desde
zustand/react/shallow.
¿Cómo seleccionas varios valores de un store sin provocar re-renderizados extra?
import { useShallow } from "zustand/react/shallow";
const { name, avatar } = useStore(
useShallow((s) => ({ name: s.user.name, avatar: s.user.avatar }))
);Gotcha: ¿Por qué (s) => ({ a: s.a, b: s.b }) provoca re-renderizados en cada cambio de state?
- Este selector crea un objeto nuevo en cada llamada.
- Como
{} !== {}, Zustand lo interpreta como un valor nuevo y provoca un re-renderizado. - Envuélvelo con
useShallowpara comparar las propiedades individuales en su lugar.
Gotcha: ¿useShallow maneja objetos profundamente anidados?
- No.
useShallowsolo compara un nivel de profundidad. - Los objetos y arrays anidados siguen usando igualdad por referencia para sus hijos.
- Para comparaciones profundas, usa una función de igualdad personalizada o selecciona valores primitivos.
¿Seleccionar una función de acción como (s) => s.increment provoca re-renderizados?
- No. Las referencias de las funciones de acción son estables en Zustand: nunca cambian.
- Seleccionar acciones es seguro y recomendado.
¿Qué son los selectores auto-generados y cuándo debes usarlos?
- Un wrapper
createSelectorsgenera hooks por campo:useStore.use.count(). - Esto elimina escribir funciones selector individuales para cada campo.
- Compromiso: añade comportamiento de "magia" que puede ser más difícil de depurar.
¿Debes poner cálculos costosos dentro de los selectores?
- No. Los selectores se ejecutan de forma síncrona durante el renderizado y deben ser rápidos.
- Selecciona los datos sin procesar y luego usa
useMemopara cálculos derivados costosos.
const items = useCartStore((s) => s.items);
const sorted = useMemo(() => [...items].sort(...), [items]);¿Puedes llamar a los selectores de forma condicional en un componente?
- No. Los hooks de Zustand siguen las reglas de los hooks de React.
- No puedes llamar a un selector dentro de un bloque
ifo después de un return condicional.
¿Cómo se tipan los selectores en TypeScript?
- El tipo de retorno del selector determina el tipo del valor.
useShallowpreserva el tipo de retorno del selector.
// el resultado está tipado como number
const count = useStore((s: StoreState) => s.count);¿Cuál es el tipo de TypeScript para la utilidad WithSelectors usada en selectores auto-generados?
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;- Mapea cada clave del state del store a una función hook.
¿Cuál es la diferencia de rendimiento entre selectores individuales y useShallow?
- Los selectores individuales son los más precisos: una suscripción por valor.
useShallowselecciona varios valores pero añade un paso de comparación superficial.- Ambos son mucho mejores que seleccionar el store completo sin selector.