Context, Redux y Zustand en Next.js: ¿Cuál usar y cuándo?

Se dio una situación en el trabajo donde un proyecto en React (React sin nada más, a la antigua, con Webpack) comenzó a precisar alguna forma de mantener un estado "global". Necesitábamos acceder a una información del usuario desde varios componentes, y a veces había 4 o 5 niveles, o incluso más, de profundidad en el árbol de jerarquía de componentes.

Muchos niveles


Si fuera un proyecto personal, habría armado un store con Zustand y listo.

import { create } from 'zustand';

const useUserStore = create((set) => ({
  email: '',
  name: '',
  setUser: (email, name) => set({ email, name }),
  clearUser: () => set({ email: '', name: '' }),
}));

export default useUserStore;

Muy simple.

Necesitamos usarlo y hacemos

import React from 'react';
import useUserStore from './userStore';

const UserProfile = () => {
  const { email, name, setUser, clearUser } = useUserStore();

  return (
    <div>
      <h2>User Profile</h2>
      <p>Email: {email || 'No email set'}</p>
      <p>Name: {name || 'No name set'}</p>
      <button onClick={() => setUser('user@example.com', 'John Doe')}>
        Set User
      </button>
      <button onClick={clearUser}>Clear User</button>
    </div>
  );
};

export default UserProfile;

¿Y entonces? ¿Qué pasó? ¿Por qué no lo usé?

Bueno, en el trabajo no es tan simple. Exigen que cada librería nueva tenga un justificativo; no sirve decir "es más simple y ya". Se tiene que analizar desde varios puntos de vista:

  • Peso de la librería
  • Facilidad de uso
  • Integración con proyectos existentes
  • Seguridad (vulnerabilidades)
  • Popularidad y mantenimiento

Todo es posible, ¿no? O sea, sin ir muy a fondo, creo que Zustand cumple con todo, pero el tema es que te van a preguntar en profundidad sobre varias cosas. Y bueno, no sé ustedes, pero yo tampoco tengo el capricho de usar la librería en el trabajo. O sea, no me voy a convertir en experto en Zustand ni dedicarle 4 horas a una expo para que quizás igual me digan: "mejor no la uses". Todo para tener dos valores en estado global.

¿Y qué alternativa me queda?

Usar Redux (que alguien ya presentó y quedó hace años) o usar Context. Context es una API de React, así que no hay que pedir permiso, obviamente. Está incluido ahí, se puede usar.

Si me conocés un poco (como dev), sabés que jamás elegiría Redux. Pero quizás no me conocés, así que te explico por qué:

  • Un poco detesto Redux y sus patrones. Tener que agregar cosas en varios archivos (dispatchers, reducers, actions) me cansa y me confunde. Siempre tengo que leer archivos ya armados y copiarlos, mi cerebro se rehúsa a aprender el patrón de memoria.
  • Si bien Redux se jacta de tener buena performance (y entiendo que está súper probado también), para este caso donde tenemos dos variables, no lo necesitamos. Dos variables, tres o veinte van a ir bien con Zustand o Context. Distinto sería si fueran mil, ¿no?
  • Redux brilla cuando necesitás un estado global muy estructurado con middleware, logging o devtools, pero para algo tan simple como almacenar datos de usuario, solo añade fricción innecesaria.

Entonces, por descarte, vamos con Context, que si bien mucho no me piacce, quedaría algo así:

UserContext.ts

import React, { createContext, useContext, useState } from 'react';

// Crear el contexto
const UserContext = createContext();

export const UserProvider = ({ children, initialUser }) => {
  const [user, setUser] = useState(initialUser);

  const setUserInfo = (email, name) => setUser({ email, name });
  const clearUser = () => setUser({ email: '', name: '' });

  return (
    <UserContext.Provider value={{ ...user, setUserInfo, clearUser }}>
      {children}
    </UserContext.Provider>
  );
};

// Hook para usar el contexto
export const useUser = () => useContext(UserContext);

Con un fetch en el root del componente que obtiene el usuario logueado:

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { UserProvider } from './UserContext';

const Root = () => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch('/api/user') 
      .then((res) => res.json())
      .then((data) => setUser(data))
      .catch(() => setUser({ email: '', name: '' })); 
  }, []);

  if (user === null) return <p>Loading...</p>;

  return (
    <UserProvider initialUser={user}>
      <App />
    </UserProvider>
  );
};

ReactDOM.render(<Root />, document.getElementById('root'));

En React, tiene sentido tener un Context para evitar tanto prop drilling.

¿Por qué nunca utilizo Context en Next.js?

Debido a que Next.js tiene sus rutas definidas en carpetas y, out of the box, trae componentes SSR, estamos cargando al usuario desde el servidor. Teniendo un layout bien armado para cada página y el estado necesario en cada una, la información del usuario y/o cualquier cosa que necesitara ser global está a uno o dos niveles del componente, tres o cuatro como máximo.

VER:  Arquitectura de Celulas de Slack

pocos niveles de profundidad

Yo creo que con cuatro niveles no tiene ni sentido meter estado global, menos para cosas tan pequeñas.

Además, la mayoría de la lógica de acciones está en archivos de acciones (actions.ts), y el data fetching ocurre en componentes asíncronos. Esto elimina la necesidad de reducers, dispatchers y useEffects en muchos casos.

Por ejemplo:
app/actions/getUser.ts

export async function getUser() {
  const res = await fetch(`${process.env.API_URL}/api/user`, { cache: 'no-store' });
  if (!res.ok) throw new Error('Failed to fetch user');
  return res.json();
}

Nuestro layout si necesitara la data:

import { getUser } from '@/lib/actions';

export default async function Layout({ children }) {
  const user = await getUser();

  return (
    <div>
      <header>
        <h1>Welcome, {user.name}</h1>
      </header>
      {children}
    </div>
  );
}

Y la página haría lo mismo.

export default async function ProfilePage() {
  const user = await getUser();
  return (
    <div>
      <h2>Profile</h2>
      <p>Email: {user.email}</p>
      <p>Name: {user.name}</p>
    </div>
  );
}

Dos llamadas a la API en vez de una, no pasa nada.

Primero:

Next.js extends the fetch API to automatically memoize requests that have the same URL and options. This means you can call a fetch function for the same data in multiple places in a React component tree while only executing it once.

Segundo:

Es solo un ejemplo. Si estuviera usando algo como next-auth, el usuario estaría definido en memoria, y en cada componente servidor que necesite el usuario:

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/utils/authOptions"
import { redirect } from "next/navigation"
import { getUserBillingInfo } from '@/services/billing'
import { getUserSites } from '@/services/site'
import { BillingCard } from '@/components/dashboard/billing-card'
import { TransactionHistory } from '@/components/billing/transaction-history'
import { User } from '@/types'

type UserWithId = User & { id: number }

export default async function BillingPage() {
    const session = await getServerSession(authOptions)
    const user = session?.user as UserWithId;

    if (!user) {
        redirect('/api/auth/signin')
    }

    const sites = await getUserSites(user.id)
    const billingInfo = await getUserBillingInfo(user.id)

    return (
        <div className="py-6">
            <div className="container mx-auto px-4 space-y-6">
                <h2 className="text-3xl font-bold tracking-tight">Billing & Payments</h2>

                <div className="space-y-6">
                    <BillingCard billingInfo={billingInfo} sites={sites} />
                    <TransactionHistory userId={user.id} />
                </div>
            </div>
        </div>
    )
}

Lo mismo pasaría con i18n. Si tuviéramos que manejar los locales sin ninguna librería, probablemente necesitaríamos Context, pero con next-intl o alguna otra, todo ese manejo lo hace la librería.

¿Cuándo usaría estado global en Next.js?

El caso donde encontré que sí necesito estado global es cuando hay ciertas opciones o información que el usuario nos da y queremos persistirla de manera temporal mientras navega por distintas rutas.

Ejemplo: un flow de "Get Started"

  • El usuario elige ciertas opciones y hace clic en pagar.
  • Se lo redirige a completar su información.
  • Se lo redirige a la página del carrito.

En este caso, tenemos que mantener en estado su info del carrito. Tiene sentido guardar esto en el browser, pero no en una BDD, ya que puede ser algo temporal.

Acá metemos un Zustand con persist, y tenemos muchos argumentos para usarlo. También podríamos hacerlo con Context.

Pero no es tan común en mis proyectos de Next.js, y mucho menos algo que pase muy seguido, salvo que la aplicación sea realmente grande. Flows así me los encuentro de vez en cuando, y en definitiva, no importa qué tanto te los encontrás, sino que entiendas cuándo un patrón debería prevalecer sobre otro.

Comparativa final:

Solución Pros Contras
Zustand Simple, mínima fricción, buena performance No es tan popular como Redux y tampoco es tanto mejor que context
Context API Nativo de React, sin dependencias Puede afectar la performance si se abusa
Redux Escalable, herramientas avanzadas Boilerplate innecesario para casos simples

Final thoughts (resumen)

En fin, si te tenés que llevar algo de esto es:

  • No metas estado global si no es necesario. Con 3 o 4 niveles, el prop drilling no es un problema real.
  • En Next.js, muchas veces Context es innecesario porque los datos se pueden obtener desde el servidor en cada componente que los necesita.
  • Redux es overkill para la mayoría de los casos simples. Si solo tenés que guardar un par de valores, mejor evitá el boilerplate.
  • Si de verdad necesitás persistir estado global en el cliente (ejemplo: un flow de checkout), Zustand con persist es una opción sólida.
  • Al final, no importa qué tan seguido te encontrás con estos casos, sino entender cuándo un patrón vale la pena y cuándo solo te va a complicar la vida.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *