Zustand con Next.js
Receta
En Next.js App Router, los stores de Zustand son singletons que persisten entre solicitudes en el servidor. Crea instancias de store por solicitud usando createStore + Context para evitar state compartido entre usuarios.
// stores/counter-store.ts
import { createStore } from "zustand/vanilla";
export interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
export const createCounterStore = (initialCount = 0) =>
createStore<CounterState>((set) => ({
count: initialCount,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
}));
export type CounterStore = ReturnType<typeof createCounterStore>;// providers/counter-provider.tsx
"use client";
import { createContext, useContext, useRef, ReactNode } from "react";
import { useStore } from "zustand";
import { createCounterStore, CounterState, CounterStore } from "@/stores/counter-store";
const CounterContext = createContext<CounterStore | null>(null);
export function CounterProvider({
children,
initialCount,
}: {
children: ReactNode;
initialCount?: number;
}) {
const storeRef = useRef<CounterStore>();
if (!storeRef.current) {
storeRef.current = createCounterStore(initialCount);
}
return (
<CounterContext.Provider value={storeRef.current}>
{children}
</CounterContext.Provider>
);
}
export function useCounterStore<T>(selector: (state: CounterState) => T): T {
const store = useContext(CounterContext);
if (!store) throw new Error("useCounterStore debe usarse dentro de CounterProvider");
return useStore(store, selector);
}Ejemplo funcional
// stores/app-store.ts
import { createStore } from "zustand/vanilla";
import { persist, createJSONStorage } from "zustand/middleware";
export interface AppState {
user: { id: string; name: string; email: string } | null;
theme: "light" | "dark";
setUser: (user: AppState["user"]) => void;
setTheme: (theme: AppState["theme"]) => void;
logout: () => void;
}
export type AppStoreType = ReturnType<typeof createAppStore>;
export const createAppStore = (initState?: Partial<AppState>) =>
createStore<AppState>()(
persist(
(set) => ({
user: null,
theme: "light",
...initState,
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
logout: () => set({ user: null }),
}),
{
name: "app-storage",
storage: createJSONStorage(() =>
typeof window !== "undefined"
? localStorage
: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
),
partialize: (state) => ({ theme: state.theme }),
}
)
);// providers/app-provider.tsx
"use client";
import { createContext, useContext, useRef, ReactNode } from "react";
import { useStore } from "zustand";
import { createAppStore, AppState, AppStoreType } from "@/stores/app-store";
const AppStoreContext = createContext<AppStoreType | null>(null);
interface AppProviderProps {
children: ReactNode;
initialState?: Partial<AppState>;
}
export function AppProvider({ children, initialState }: AppProviderProps) {
const storeRef = useRef<AppStoreType>();
if (!storeRef.current) {
storeRef.current = createAppStore(initialState);
}
return (
<AppStoreContext.Provider value={storeRef.current}>
{children}
</AppStoreContext.Provider>
);
}
export function useAppStore<T>(selector: (state: AppState) => T): T {
const store = useContext(AppStoreContext);
if (!store) throw new Error("useAppStore debe usarse dentro de AppProvider");
return useStore(store, selector);
}// app/layout.tsx (Server Component)
import { AppProvider } from "@/providers/app-provider";
import { cookies } from "next/headers";
async function getServerUser() {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (!token) return null;
const res = await fetch("https://api.example.com/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
return res.json();
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getServerUser();
return (
<html lang="en">
<body>
<AppProvider initialState={{ user }}>
{children}
</AppProvider>
</body>
</html>
);
}// app/dashboard/page.tsx (Server Component)
import { DashboardClient } from "./dashboard-client";
async function getDashboardData() {
const res = await fetch("https://api.example.com/dashboard", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function DashboardPage() {
const data = await getDashboardData();
return <DashboardClient serverData={data} />;
}// app/dashboard/dashboard-client.tsx
"use client";
import { useAppStore } from "@/providers/app-provider";
export function DashboardClient({ serverData }: { serverData: any }) {
const user = useAppStore((s) => s.user);
const theme = useAppStore((s) => s.theme);
return (
<div className={theme === "dark" ? "bg-gray-900 text-white" : "bg-white"}>
<h1>¡Hola, {user?.name ?? "Invitado"}</h1>
<pre>{JSON.stringify(serverData, null, 2)}</pre>
</div>
);
}Análisis profundo
Cómo funciona
- En Next.js App Router, los módulos se cargan una vez en el servidor y se comparten entre todas las solicitudes. Un store
create()a nivel de módulo filtraría state entre usuarios. createStoredezustand/vanillacrea una instancia de store sin hooks de React. Envolverlo en Context +useRefgarantiza una instancia por árbol de React.- El Provider crea el store una vez al montar (mediante
useRef) y nunca lo vuelve a crear, ni siquiera entre re-renderizados. - Los Server Components obtienen datos y los pasan como
initialStateal Provider de Cliente, que inicializa el store. useStore(store, selector)de Zustand conecta un store vanilla a React con suscripciones basadas en selectores.- El adaptador de almacenamiento del middleware
persistdebe manejar el entorno del servidor, dondelocalStorageno está definido.
Variaciones
Store singleton simple (úsalo solo si es aceptable que el state se filtre en SSR):
// Aceptable para state verdaderamente global como feature flags que son
// iguales para todos los usuarios
import { create } from "zustand";
export const useFeatureFlags = create<{ flags: Record<string, boolean> }>(() => ({
flags: {},
}));Persist seguro para hidratación con onRehydrateStorage:
createStore<State>()(
persist(storeCreator, {
name: "store",
onRehydrateStorage: () => {
return (state, error) => {
if (error) console.error("Falló la hidratación:", error);
else console.log("Hidratado:", state);
};
},
})
);Stores por ruta:
// app/checkout/layout.tsx
import { CheckoutProvider } from "@/providers/checkout-provider";
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
return <CheckoutProvider>{children}</CheckoutProvider>;
}
// El store solo existe dentro del segmento de ruta checkoutNotas de TypeScript
- Usa
createStoreen lugar decreatepara stores vanilla en Next.js. Los tipos difieren. - El patrón de provider requiere tipar el context, el hook selector y el creador del store por separado.
import { createStore, StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
type MyStore = StoreApi<MyState>;
// Context tipado
const StoreContext = createContext<MyStore | null>(null);
// Hook selector tipado
function useMyStore<T>(selector: (state: MyState) => T): T {
const store = useContext(StoreContext);
if (!store) throw new Error("Falta el provider");
return useStore(store, selector);
}Trampas comunes
- Un store
create()a nivel de módulo en Next.js App Router comparte state entre todas las solicitudes renderizadas en el servidor. El usuario A podría ver los datos del usuario B. Usa siempre el patrón Context + createStore para state específico del usuario. - El middleware
persistaccede alocalStorageal importar, lo que lanza un error en entornos de servidor. Protégelo contypeof window !== "undefined"o usa un almacenamiento no-op en el servidor. - Las desincronizaciones de hidratación ocurren cuando el HTML renderizado en el servidor usa el state por defecto, pero el cliente hidrata con state persistido. Usa una compuerta de hidratación o suprime advertencias de hidratación para diferencias conocidas.
useRefpara el store evita la recreación, pero también significa que los cambios deinitialStatedespués del primer renderizado se ignoran. Si necesitas reinicializar, usa una propkeyen el Provider.- No importes hooks de Client Component (
useAppStore) en Server Components. Los Server Components no pueden usar hooks. - El callback
onRehydrateStoragedel middleware persist se ejecuta después del renderizado inicial. Los componentes pueden mostrar brevemente el state por defecto.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Context + createStore | Seguro en SSR, aislamiento por solicitud | Más boilerplate que un singleton |
| create() a nivel de módulo | Simple, sin provider | Filtra state en SSR |
| Redux Toolkit + next-redux-wrapper | Patrón SSR maduro | Pesado, configuración compleja |
| Jotai con Provider | Atómico, seguro en SSR con Provider | Paradigma distinto |
FAQs
¿Por qué no puedes usar un store create() a nivel de módulo en Next.js App Router para state específico del usuario?
- Los módulos se cargan una vez en el servidor y se comparten entre todas las solicitudes.
- Un store singleton filtraría el state del usuario A en la solicitud del usuario B.
- Usa el patrón Context +
createStorepara aislamiento por solicitud.
¿Qué es el patrón Context + createStore para Next.js?
- Usa
createStoredezustand/vanillapara crear instancias de store (no hooks de React). - Envuélvelo en un provider de React Context que crea una instancia por montaje (mediante
useRef). - Expón un hook selector tipado usando
useStore(store, selector).
¿Cómo pasas datos obtenidos en el servidor a un store de Zustand en Next.js?
- Obtén los datos en un Server Component y pásalos como
initialStateal Provider de Cliente. - El Provider inicializa el store con esos datos en el primer renderizado.
// Server Component
const user = await getServerUser();
return <AppProvider initialState={{ user }}>{children}</AppProvider>;¿Por qué useRef evita que el store se vuelva a crear en los re-renderizados?
useRefmantiene el mismo valor entre renderizados sin provocar re-renderizados.- El store se crea una vez al montar dentro de la comprobación
if (!storeRef.current). - Esto también significa que los cambios de
initialStatedespués del primer renderizado se ignoran.
¿Cuándo es aceptable un store singleton simple en Next.js?
- Para state verdaderamente global que es igual para todos los usuarios (p. ej., feature flags).
- Cuando no es un problema que el state se filtre en SSR.
- Usa
create()(nocreateStore) para este patrón más simple.
Trampa: ¿Qué ocurre si el middleware persist accede a localStorage durante SSR?
- Lanza un error porque
localStorageno está disponible en el servidor. - Protégelo con
typeof window !== "undefined"o proporciona un almacenamiento no-op para el servidor.
Trampa: ¿Por qué podrías ver una desincronización de hidratación con state persistido?
- El HTML renderizado en el servidor usa el state por defecto, pero el cliente hidrata con state persistido (distinto).
- Esto provoca una desincronización. Usa una compuerta de hidratación o suprime advertencias para diferencias conocidas.
¿Cómo limitas un store de Zustand a un segmento de ruta específico?
// app/checkout/layout.tsx
export default function CheckoutLayout({ children }) {
return <CheckoutProvider>{children}</CheckoutProvider>;
}- Coloca el provider en un layout de ruta. El store solo existe dentro de ese segmento de ruta.
¿Puedes importar un hook de Zustand (como useAppStore) en un Server Component?
- No. Los Server Components no pueden usar hooks.
- Solo importa y usa hooks de Zustand en Client Components marcados con
"use client".
¿Cuál es la diferencia entre create y createStore en TypeScript?
createdevuelve un hook de React (UseBoundStore<StoreApi<State>>).createStoredevuelve un store vanilla (StoreApi<State>) sin enlaces de React.- Usa
createStore+useStore(store, selector)para el patrón de provider en Next.js.
import { createStore, StoreApi } from "zustand/vanilla";
type MyStore = StoreApi<MyState>;¿Cómo tipas el hook selector del provider en TypeScript?
function useAppStore<T>(selector: (state: AppState) => T): T {
const store = useContext(AppStoreContext);
if (!store) throw new Error("Falta el provider");
return useStore(store, selector);
}- El genérico
Tinfiere el tipo de retorno a partir de la función selector.