Manejo de estado global con Zustand en Next.js – Login y persistencia

Manejo de estado global con Zustand en Next.js – Login y persistencia

Artículospor Luis

Zustand es una librería de manejo de estado global para React, ligera y muy poderosa. En este tutorial vamos a integrarla en un proyecto con Next.js (App Router) para:

  • Simular un login con un endpoint mock

  • Guardar y persistir los datos del usuario

  • Proteger rutas

  • Mostrar la información del usuario en un Header

  • Pasar datos al componente Slider

Vamos a trabajar directamente sobre un proyecto Next.js ya creado, usando la estructura con app, components, stores, etc.

Estructura base

Nuestro árbol de carpetas relevante será:

src/
├── app/
│   ├── page.tsx         // Login (index)
│   └── dashboard/
│       └── page.tsx     // Página interna
├── components/
│   ├── Header.tsx
│   └── Slider.tsx
├── hooks/
│   └── useUserClient.ts
├── mocks/
│   └── user.ts
└── stores/
    └── userStore.ts

 

Instalando Zustand

Si aún no lo tienes instalado:

npm install zustand

Y para persistencia:

npm install zustand/middleware

1. Mock del endpoint de login

Creamos un login simulado en src/mocks/user.ts:

export async function mockLogin(username: string, password: string) {
  await new Promise((r) => setTimeout(r, 500));

  if (username === "admin" && password === "1234") {
    return {
      id: 1,
      name: "Luis Velito",
      email: "admin@example.com",
      role: "admin",
      sliderValue: 60,
      sliderNegative: true,
      steps: 10,
      showTooltip: "always",
    };
  }

  throw new Error("Invalid credentials");
}

2. Zustand store con persistencia

Creamos el store en src/stores/userStore.ts:

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface User {
  id: number;
  name: string;
  email: string;
  role: string;
  sliderValue: number;
  sliderNegative: boolean;
  steps: number;
  showTooltip: string;
}

interface UserStore {
  user: User | null;
  login: (data: User) => void;
  logout: () => void;
}

export const useUserStore = create<UserStore>()(
  persist(
    (set) => ({
      user: null,
      login: (data) => set({ user: data }),
      logout: () => set({ user: null }),
    }),
    {
      name: "user-storage", // clave en localStorage
    }
  )
);

3. Hook useUserClient para manejar hidratación

En Next.js con SSR, hay que esperar a que Zustand se hidrate. Creamos un hook en src/hooks/useUserClient.ts:

"use client";

import { useEffect, useState } from "react";
import { useUserStore } from "@/stores/userStore";

export function useUserClient() {
  const user = useUserStore((s) => s.user);
  const [isHydrated, setIsHydrated] = useState(false);

  useEffect(() => {
    setIsHydrated(true);
  }, []);

  return { user, isHydrated };
}

4. Página de login (/)

Usamos el mock de login y guardamos la sesión con Zustand:

// src/app/page.tsx
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { mockLogin } from "@/mocks/user";
import { useUserStore } from "@/stores/userStore";
import { useUserClient } from "@/hooks/useUserClient";

export default function LoginPage() {
  const router = useRouter();
  const login = useUserStore((s) => s.login);
  const { user } = useUserClient();

  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  useEffect(() => {
    if (user) router.push("/dashboard");
  }, [user]);

  const handleLogin = async () => {
    try {
      const loggedUser = await mockLogin(username, password);
      login(loggedUser);
    } catch (err) {
      setError("Credenciales inválidas: " + err);
    }
  };

  return (
    <div className="flex items-center justify-center min-h-screen min-w-screen bg-gray-200">
      <div className="bg-white p-10 rounded-lg shadow-md text-gray-700">
        <h1 className="text-2xl font-bold mb-4">Iniciar sesión</h1>
        <input
          className="border p-2 w-full mb-2"
          placeholder="Usuario"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
        <input
          className="border p-2 w-full mb-4"
          type="password"
          placeholder="Contraseña"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        <button className="bg-black text-white px-4 py-2" onClick={handleLogin}>
          Entrar
        </button>
        {error && <p className="text-red-600 mt-2">{error}</p>}
      </div>
    </div>
  );
}

5. Página interna /dashboard

Protegida, con redirección si no hay sesión:

// src/app/dashboard/page.tsx
"use client";

import Header from "@/components/Header";
import Slider from "@/components/Slider";
import { useUserClient } from "@/hooks/useUserClient";

export default function DashboardPage() {
  const { user, isHydrated } = useUserClient();

  if (!isHydrated) return null;
  if (!user) return null;

  return (
    <div className="min-h-screen min-w-screen bg-gray-200">
      <Header />
      <div className="flex items-center justify-center container mx-auto p-4">
        <div className="bg-white p-8 rounded-lg shadow-md min-w-[600px]">
          <h2 className="text-xl font-semibold mb-12 text-gray-700">Slider personalizado</h2>
          <Slider
            value={user.sliderValue}
            negative={user.sliderNegative}
            steps={user.steps}
            showTooltip={user.showTooltip as "always" | "onInteraction" | undefined}
          />
        </div>
      </div>
    </div>
  );
}

5. Componente Header con cierre de sesión

Código del componente header:

// src/components/Header.tsx
"use client";

import { useUserClient } from "@/hooks/useUserClient";
import { useUserStore } from "@/stores/userStore";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function Header() {
  const { user, isHydrated } = useUserClient();
  const logout = useUserStore((s) => s.logout);
  const router = useRouter();

  useEffect(() => {
    if (isHydrated && !user) {
      router.push("/");
    }
  }, [user, isHydrated]);

  if (!isHydrated || !user) return null;

  return (
    <header className="flex justify-between items-center p-4 bg-gray-100 rounded-md shadow">
      <div>
        <p className="text-sm text-gray-700">Bienvenido,</p>
        <p className="font-bold text-gray-900 text-lg">{user.name}</p>
      </div>
      <div className="flex flex-col items-end">
        <p className="text-sm text-gray-600">{user.email}</p>
        <p
          className="mt-4 text-red-500 cursor-pointer text-xs hover:text-red-600 uppercase"
          onClick={() => {
            logout();
            router.push("/");
          }}
        >
          Cerrar sesión
        </p>
      </div>
    </header>
  );
}

Resultado final

  • ✅ Login simulado con Zustand

  • ✅ Datos persistentes entre recargas

  • ✅ Página protegida con redirección

  • ✅ Header con sesión e información

  • ✅ Slider dinámico con props desde el store

Conclusión

Zustand es ideal para manejar sesiones de usuario en aplicaciones React y Next.js, sin el peso de soluciones más complejas. Con persist, una estructura clara y algunos hooks de cliente, puedes construir experiencias sólidas y modernas fácilmente.

-- ¿Quieres recibir mi newsletter? --

No te pierdas de aprender:

Mi newsletter mensual viene con una dosis de inspiración, recursos para descargar, consejos de desarrollo rápidos y los mismos recursos que aprendo.