Caso práctico: sesión y notificaciones con Zustand
Ahora aplicamos todo sobre el proyecto de la semana 12. Tienes un login que guarda el token en localStorage mediante session.ts, un AuthGuard y un dashboard. En esta práctica:
- Migramos la sesión a un store de Zustand con
persist, de modo que cualquier componente pueda reaccionar al login y al logout. - Construimos un sistema de notificaciones que se emite desde una pantalla y se lee en la barra de navegación, sin pasar
props. - Verificamos con Redux DevTools qué ocurre en cada cambio.
Trabaja sobre tu proyecto de la semana 12. Debes tener funcionando LoginForm, RegisterForm, AuthGuard, apiFetch y el layout (app).
1. Qué vamos a cambiar
| Archivo (semana 12) | Qué pasa en la semana 13 |
|---|---|
shared/lib/session.ts | Se reemplaza por shared/store/session.store.ts |
shared/lib/api.ts | Lee el token del store con getState() |
features/auth/services/auth.service.ts | Guarda y borra el token en el store |
features/auth/components/AuthGuard.tsx | Espera a que la sesión se restaure y lee el store con un selector |
app/(app)/layout.tsx | Restaura la sesión y muestra la campana de notificaciones |
(nuevo) features/notifications/... | Store, creador y campana de notificaciones |
2. Paso a paso
Instalar Zustand
npm install zustand
Crear el store de sesión
Crea src/shared/store/session.store.ts. Guarda solo el token y una bandera para saber si la sesión ya se restauró desde localStorage.
Conectar el cliente HTTP y el servicio de autenticación
api.ts y auth.service.ts leen y escriben el token en el store.
Actualizar AuthGuard y el layout
El guardián espera la restauración; el layout la dispara una vez.
Crear el store de notificaciones y sus componentes
Reutiliza el store de la lección 3.
Emitir notificaciones desde el login
Avisa cuando se inicia y se cierra la sesión, y mira el resultado en Redux DevTools.
3. El store de sesión
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
type SessionState = {
token: string | null;
hasHydrated: boolean;
setToken: (token: string) => void;
clearSession: () => void;
setHasHydrated: (value: boolean) => void;
};
export const useSessionStore = create<SessionState>()(
devtools(
persist(
(set) => ({
token: null,
hasHydrated: false,
setToken: (token) => set({ token }, undefined, 'session/setToken'),
clearSession: () => set({ token: null }, undefined, 'session/clear'),
setHasHydrated: (value) => set({ hasHydrated: value }, undefined, 'session/hydrated'),
}),
{
name: 'auth_token', // misma clave que la semana 12
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ token: state.token }),
skipHydration: true, // restauramos nosotros, después del primer render
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
},
),
{ name: 'SessionStore', enabled: process.env.NODE_ENV !== 'production' },
),
);
Qué decisiones hay aquí, y de qué lección vienen:
partializeguarda solotoken.hasHydratedes un dato de la sesión de navegación, no algo que deba persistir.skipHydrationevita el desfase de hidratación del que hablamos en la lección 4.onRehydrateStoragenos avisa cuando terminó de restaurar, para encenderhasHydrated.- Nombres de acción (tercer argumento de
set) para leer el historial en Redux DevTools.
persist guarda un objeto JSON ({"state":{"token":"..."},"version":0}), mientras que session.ts guardaba el token como texto plano bajo auth_token. Si tu navegador conserva una sesión de la semana 12, borra esa clave (localStorage.removeItem('auth_token') en la consola) o inicia sesión de nuevo.
4. Conectar el cliente HTTP y el servicio
api.ts ya no importa session. Lee el token con getState(), porque no es un componente:
import { useSessionStore } from '@/shared/store/session.store';
export async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = useSessionStore.getState().token;
// ... el resto queda igual que en la semana 12
}
El servicio de autenticación escribe en el store en lugar de localStorage:
import { useSessionStore } from '@/shared/store/session.store';
export const authService = {
async login(credentials: LoginCredentials): Promise<string> {
const token = await apiFetch<string>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
useSessionStore.getState().setToken(token);
return token;
},
logout(): void {
useSessionStore.getState().clearSession();
},
// register() no cambia
};
authService y apiFetch son funciones normales, no componentes. Los hooks solo se pueden llamar dentro de componentes, mientras que getState() funciona en cualquier archivo.
5. AuthGuard y layout
El guardián ya no consulta localStorage. Usa dos selectores y un useEffect para decidir:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useSessionStore } from '@/shared/store/session.store';
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const hasHydrated = useSessionStore((s) => s.hasHydrated);
const isAuthenticated = useSessionStore((s) => s.token !== null);
useEffect(() => {
if (hasHydrated && !isAuthenticated) {
router.replace('/login');
}
}, [hasHydrated, isAuthenticated, router]);
if (!hasHydrated) {
return <p className="p-8">Verificando autorización de acceso...</p>;
}
if (!isAuthenticated) return null;
return <>{children}</>;
}
Observa lo que ganamos: si el token se borra en cualquier parte (un logout, un 401 del backend), el guardián se entera solo y redirige. En la semana 12 solo comprobaba una vez, al montarse.
El layout restaura la sesión una vez y muestra la campana:
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { authService } from '@/features/auth/services/auth.service';
import { AuthGuard } from '@/features/auth/components/AuthGuard';
import { NotificationBell } from '@/features/notifications/components/NotificationBell';
import { useSessionStore } from '@/shared/store/session.store';
export default function AppLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
// Restaurar la sesión guardada, una sola vez al montar
useEffect(() => {
useSessionStore.persist.rehydrate();
}, []);
const handleLogout = () => {
authService.logout();
router.replace('/login');
};
return (
<AuthGuard>
<header className="navbar border-b border-base-200 px-4">
<div className="flex-1" />
<NotificationBell />
<button type="button" className="btn btn-ghost" onClick={handleLogout}>
Cerrar sesión
</button>
</header>
<main className="p-8">{children}</main>
</AuthGuard>
);
}
rehydrate() se llama en el layout, no dentro de AuthGuard, para que ocurra una sola vez por carga de página y no cada vez que el guardián se monte. Si la página de login también necesita saber si ya hay sesión (para saltarse el formulario), aplica el mismo patrón en app/(auth)/layout.tsx.
6. Notificaciones
Crea el store con el código de la lección 3 en features/notifications/store/notifications.store.ts, y los componentes NotificationCreator y NotificationBell de la sección 4 de esa lección. Con eso NotificationBell ya se actualiza cuando alguien emite una notificación.
Emitir desde el login
Fuera de React se llama la acción con getState(). Agrega esto en auth.service.ts:
import { useNotificationsStore } from '@/features/notifications/store/notifications.store';
// dentro de login(), después de guardar el token:
useNotificationsStore.getState().addNotification('Inicio de sesión exitoso');
// dentro de logout():
useNotificationsStore.getState().addNotification('Sesión cerrada');
Ahora el login y el logout avisan a la barra de navegación sin que ningún componente se los pase. Esa es toda la conexión.
7. Verificar con Redux DevTools
Instala la extensión Redux DevTools y abre la pestaña en la página del dashboard. Comprueba:
- Al cargar la página aparece
session/hydrated: la sesión se restauró. - Al iniciar sesión ves
session/setTokencon el token en el estado. - Al cerrar sesión ves
session/clear. - Con la pestaña Diff, confirma que cada acción cambió solo lo que debía.
8. Criterios de entrega
Funcional
El login guarda la sesión en el store; recargar la página la conserva; cerrar sesión la borra y redirige a /login.
Estructura
Cada store en su carpeta (shared/store o features/<x>/store), con tipos separados en datos y acciones.
Rendimiento
Todos los componentes usan selectores; NotificationCreator no se re-renderiza al llegar una notificación.
Evidencia
Captura de Redux DevTools con las acciones session/hydrated, session/setToken y session/clear.
Retos opcionales
- Marcar como leídas: agrega un menú desplegable con la lista de notificaciones y los botones
markReadymarkAllRead. - Persistir las notificaciones: aplica
persistconpartializeal store de notificaciones y decide si deben sobrevivir al cierre de sesión (pista:subscribeysetStatede la lección 4). - Tema claro y oscuro: crea
shared/store/ui.store.tscon el tema y una accióntoggleTheme. - Responder al 401: si
apiFetchrecibe un401, llamaclearSession()y deja que elAuthGuardexpulse al usuario.