Next.jsReactTypeScriptRefactoringZustand

Refatorando o Gerenciamento de Estado com Next.js × Zustand

Sloth255
Sloth255
·3 min read·608 words

Contexto e Desafios

Este app usa Next.js 15 (App Router) + React 19 com configuração de exportação estática. O caso de uso envolve recuperar, exibir e compartilhar informações de rede (IPv4/IPv6, etc.) e informações do dispositivo (UA, etc.). Como é localizado, há páginas para cada idioma.

Desafios antes da implementação:

  • Lógica de recuperação espalhada pelos componentes com duplicação (difícil de rastrear efeitos colaterais)
  • Fetches repetidos em cada troca de idioma, mesmo durante a mesma sessão (parece lento)
  • Necessidade de acesso consistente ao estado a partir de recursos transversais como botões de compartilhamento

Para resolver isso, introduzimos o Zustand e refatoramos para gerenciamento de estado orientado a store (inicialização, persistência, TTL, retry).

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

Objetivos

  • Consolidar os efeitos colaterais no "lado do store de dados" (fetch, salvamento, decisões de refetch)
  • Simplificar a renderização dos componentes tornando-os "somente leitura"
  • Acelerar a performance percebida com persistência de sessão (evitar refetch dentro da mesma sessão)
  • Melhorar a confiabilidade com TTL e retry, permitir atualizações manuais pela interface

Design Adotado

1) Padrão de Slices + Bounded Store

  • slices:
    • NetworkSlice: networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
    • DeviceSlice: deviceInfo, deviceReady, initializeDeviceInfo
  • types: Definir tipos NetworkInfo/DeviceInfo e tipos Slice/Store em types.ts
  • store:
    • Criar um bounded store com createStore, compondo slices
    • Persistência de sessão com persist(createJSONStorage(() => sessionStorage))

Métodos de leitura:

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

2) Inicialização no Lado do Store

  • Rede
    • initializeNetworkInfo(opts?: { force?: boolean })
    • Gerenciar flags de loading/error
    • Salvar networkFetchedAt = Date.now() no sucesso (para determinação de TTL)
  • Dispositivo
    • Coletar de navigator/window com initializeDeviceInfo()
    • Gerenciar prontidão de exibição com deviceReady (exibição suave como antes)

3) Auto-inicio Após Reidratação com Medidas de Segurança

  • Em onRehydrateStorage, acionar condicionalmente a inicialização com base no estado restaurado
    • Iniciar initializeNetworkInfo() se networkInfo estiver ausente
    • Definir deviceReady = true imediatamente se deviceInfo existir, caso contrário inicializar
  • Proteção via microtask
    • Para lidar com casos em que diferenças de timing de reidratação impedem o disparo, executar uma verificação de inicialização uma vez em microtask no lado do cliente

Implementação Concreta

Tipos do 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 e 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 via 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 e Retry

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' });
  }
}

Simplificação de Componentes e Melhorias na Interface

  • Removidos efeitos colaterais (useEffect/useLayoutEffect), controlando a exibição com flags do store (loading/error/ready)
  • Adicionado botão "Atualizar" para atualizações manuais (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','Atualizando...') : L('network.refresh','Atualizar')}
>
  {networkLoading ? <Spinner/> : <span>{L('network.refresh','Atualizar')}</span>}
</button>

Armadilhas Encontradas e Soluções

  • "Estado de carregamento travado"
    • Causa: Flags não atualizados devido a diferenças de timing de reidratação
    • Solução: Definir explicitamente ready em onRehydrateStorage + proteção via microtask
  • Alvos de persistência
    • Introduzido partialize para evitar salvar flags transitórios (loading/error/ready)

Resultados

  • Melhora na performance percebida
    • Restauração instantânea do persist durante a sessão → Pular fetch (dentro do TTL)
    • Eliminação de efeitos colaterais duplicados, redução de re-renders desnecessários
  • Confiabilidade melhorada
    • Inicialização segura imediatamente após reidratação (onRehydrateStorage + microtask)
    • Retry em falha e atualização manual pela interface
  • Legibilidade e manutenibilidade
    • Lógica de recuperação consolidada (unificada no store)
    • Separação de responsabilidades por slice (network/device)
    • API clara com Bounded store

Resumo

Ao introduzir o Zustand com padrão de slices + Bounded Store e adotar a separação de papéis de "inicialização no store" e "UI lê apenas", melhoramos a velocidade de exibição, confiabilidade e manutenibilidade.
Anteriormente construí um app com React × Redux para gerenciamento de estado, mas o Zustand foi mais fácil de implementar e o código ficou mais simples, deixando uma impressão positiva.