Next.jsReactTypeScriptRefactoringZustand

Refactorización de la gestión de estado con Next.js × Zustand

Sloth255
Sloth255
·3 min read·630 words

Contexto y desafíos

Esta aplicación usa Next.js 15 (App Router) + React 19 con configuración de exportación estática. El caso de uso implica recuperar, mostrar y compartir información de red (IPv4/IPv6, etc.) e información del dispositivo (UA, etc.). Como está localizada, hay páginas para cada idioma.

Desafíos antes de la implementación:

  • Lógica de recuperación dispersa en componentes con duplicación (difícil de rastrear efectos secundarios)
  • Fetches repetidos en cada cambio de idioma incluso durante la misma sesión (se siente lento)
  • Necesidad de acceso consistente al estado desde funciones transversales como botones de compartir

Para resolver esto, introdujimos Zustand y refactorizamos hacia la gestión de estado basada en store (inicialización, persistencia, TTL, reintento).

Zustand: https://zustand-demo.pmnd.rs/

Objetivos

  • Consolidar los efectos secundarios en el "lado del store de datos" (fetching, guardado, decisiones de refetch)
  • Simplificar el renderizado de componentes haciéndolos "solo lectura"
  • Mejorar el rendimiento percibido con persistencia de sesión (evitar refetching dentro de la misma sesión)
  • Mejorar la fiabilidad con TTL y reintento, habilitar actualizaciones manuales desde la UI

Diseño adoptado

1) Patrón Slice + Bounded Store

  • slices:
    • NetworkSlice: networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
    • DeviceSlice: deviceInfo, deviceReady, initializeDeviceInfo
  • types: Define los tipos NetworkInfo/DeviceInfo y los tipos Slice/Store en types.ts
  • store:
    • Crear un bounded store con createStore, componiéndolo con slices
    • Persistencia de sesión con persist(createJSONStorage(() => sessionStorage))

Métodos de lectura:

  • Estado completo: const { networkInfo, deviceInfo } = useAppStore();
  • Selector: const info = useAppStore(s => s.networkInfo);

2) Inicialización en el lado del Store

  • Red
    • initializeNetworkInfo(opts?: { force?: boolean })
    • Gestionar flags de loading/error
    • Guardar networkFetchedAt = Date.now() en caso de éxito (para determinación de TTL)
  • Dispositivo
    • Recopilar desde navigator/window con initializeDeviceInfo()
    • Gestionar la disponibilidad de visualización con deviceReady (visualización fluida como antes)

3) Auto-inicio después de rehidratación con medidas de seguridad

  • En onRehydrateStorage, activar condicionalmente la inicialización basándose en el estado restaurado
    • Lanzar initializeNetworkInfo() si networkInfo está ausente
    • Establecer deviceReady = true inmediatamente si deviceInfo existe, de lo contrario inicializar
  • Failsafe de microtask
    • Para manejar casos donde las diferencias de timing de rehidratación impiden su activación, ejecutar una verificación de inicialización una vez en microtask en el lado del cliente

Implementación concreta

Tipos del Store

src/store/types.ts
export interface NetworkSlice {
  networkInfo: NetworkInfo | null;
  networkLoading: boolean;
  networkError: string | null;
  networkFetchedAt?: number | null; // Para TTL
  setNetworkInfo: (info: NetworkInfo | null) => void;
  initializeNetworkInfo: (opts?: { force?: boolean }) => Promise<void>;
}

export interface DeviceSlice {
  deviceInfo: DeviceInfo | null;
  deviceReady: boolean;
  setDeviceInfo: (info: DeviceInfo | null) => void;
  initializeDeviceInfo: () => void;
}

Bounded Store y persist

src/store/useAppStore.ts
export const appStore = createStore<AppStore>()(
  persist(
    (set, get, api) => ({
      ...createNetworkSlice(set, get, api),
      ...createDeviceSlice(set, get, api),
    }),
    {
      name: 'own-info-app-storage',
      storage: createJSONStorage(() => sessionStorage),
      partialize: (state) => ({
        networkInfo: state.networkInfo,
        networkFetchedAt: state.networkFetchedAt ?? null,
        deviceInfo: state.deviceInfo,
      }),
      onRehydrateStorage: () => (rehydrated) => {
        if (typeof window === 'undefined') return;
        const s = appStore.getState();
        if (!rehydrated?.networkInfo) s.initializeNetworkInfo().catch(() => {});
        if (rehydrated?.deviceInfo) appStore.setState({ deviceReady: true });
        else s.initializeDeviceInfo();
      },
    }
  )
);

// Seguro de microtask
if (typeof window !== 'undefined') {
  queueMicrotask(() => {
    const s = appStore.getState();
    if (!s.networkInfo && !s.networkLoading) s.initializeNetworkInfo().catch(() => {});
    if (s.deviceInfo && !s.deviceReady) appStore.setState({ deviceReady: true });
    else if (!s.deviceInfo && !s.deviceReady) s.initializeDeviceInfo();
  });
}

TTL y Reintento

src/app/config.ts
export const networkConfig = {
  ttlMinutes: 60,
  maxRetries: 1,
  retryDelayMs: 500,
} as const;
src/store/networkSlice.ts
import { networkConfig } from '@/app/config';
const DEFAULT_TTL_MS = Math.max(0, networkConfig.ttlMinutes) * 60 * 1000;
const MAX_RETRIES = Math.max(0, networkConfig.maxRetries);
const RETRY_DELAY_MS = Math.max(0, networkConfig.retryDelayMs);

initializeNetworkInfo: async ({ force } = {}) => {
  const { networkInfo, networkLoading, networkFetchedAt } = get();
  if (networkLoading) return;
  const fresh = networkInfo && networkFetchedAt && Date.now() - networkFetchedAt < DEFAULT_TTL_MS;
  if (!force && fresh) return;

  set({ networkLoading: true, networkError: null });
  try {
    const attemptFetch = async () => {
      const token = process.env.TOKEN;
      const [v4Res, v6Res] = await Promise.all([
        fetch(`https://<api>/v4?token=${token}`),
        fetch(`https://<api>/v6?token=${token}`),
      ]);
      const v4Json = v4Res.ok ? await v4Res.json() : '';
      const v6Json = v6Res.ok ? await v6Res.json() : '';
      return {
        ipv4: v4Json?.ip,
        ipv6: v6Json?.ip
      };
    };

    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      try {
        const data = await attemptFetch();
        set({ networkInfo: data, networkFetchedAt: Date.now(), networkLoading: false, networkError: null });
        return;
      } catch {
        if (attempt < MAX_RETRIES) {
          await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
          continue;
        }
      }
    }
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  } catch {
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  }
}

Simplificación de componentes y mejoras de UI

  • Se eliminaron los efectos secundarios (useEffect/useLayoutEffect), controlando la visualización con flags del store (loading/error/ready)
  • Se añadió un botón "Actualizar" para actualizaciones manuales (refetch ignorando TTL)
src/components/NetworkInfoClient.tsx
const { networkInfo: info, networkLoading, networkError, initializeNetworkInfo } = useAppStore();

<button
  type="button"
  onClick={() => initializeNetworkInfo({ force: true })}
  disabled={networkLoading}
  className={...}
  title={networkLoading ? L('network.refreshing','Actualizando...') : L('network.refresh','Actualizar')}
>
  {networkLoading ? <Spinner/> : <span>{L('network.refresh','Actualizar')}</span>}
</button>

Problemas encontrados y soluciones

  • "Atascado en estado de carga"
    • Causa: Flags no actualizados debido a diferencias de timing de rehidratación
    • Solución: Establecer ready explícitamente en onRehydrateStorage + failsafe de microtask
  • Objetivos de persistencia
    • Se introdujo partialize para evitar guardar flags transitorios (loading/error/ready)

Resultados

  • Mejora del rendimiento percibido
    • Restauración instantánea desde persist durante la sesión → Omitir fetch (dentro del TTL)
    • Eliminación de efectos secundarios duplicados, reducción de re-renderizados innecesarios
  • Fiabilidad mejorada
    • Inicialización segura inmediatamente después de la rehidratación (onRehydrateStorage + microtask)
    • Reintento en caso de fallo y actualización manual desde la UI
  • Legibilidad y mantenibilidad
    • Lógica de recuperación consolidada (unificada en el store)
    • Separación de responsabilidades por slice (network/device)
    • API clara con Bounded store

Resumen

Al introducir Zustand con patrón slice + Bounded Store y adoptar la separación de roles de "inicialización en el store" y "la UI solo lee", mejoramos la velocidad de visualización, la fiabilidad y la mantenibilidad.
Anteriormente construí una aplicación con React × Redux para la gestión de estado, pero Zustand fue más fácil de implementar y el código fue más simple, dejando una impresión positiva.