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))
- Criar um bounded store com
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)
- Coletar de navigator/window com
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 = trueimediatamente se deviceInfo existir, caso contrário inicializar
- Iniciar
- 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
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
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
export const networkConfig = {
ttlMinutes: 60,
maxRetries: 1,
retryDelayMs: 500,
} as const;
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)
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.
