Sincronizar estado entre Server e Client Components com use e useSyncExternalStore
Por John Pires · 12 de março de 2026

Sincronizar estado entre Server & Client Components com use e useSyncExternalStore
1. Problema real que resolve
Contexto prático
Aplicações modernas com React (especialmente usando o App Router e Server Components) frequentemente dividem a UI entre componentes que rodam no servidor e componentes que rodam no cliente. Um padrão recorrente: o servidor tem a fonte de verdade (DB, cache, API protegida) e os clientes precisam exibir e reagir a atualizações em tempo real ou quase real. O desafio é manter essa sincronização sem causar repaints desnecessários, sem quebrar a hidratação e sem introduzir problemas de inconsistência entre HTML pré-renderizado e estado client-side.
Problema técnico
Sem um padrão claro, você enfrenta problemas comuns:
Hydration mismatch: markup do servidor diferente do estado inicial client.
Re-fetchs desnecessários no cliente logo após a hidratação.
Condições de corrida entre atualizações via WebSocket e fetch inicial.
Performance degradada com re-renders globais quando apenas parte do estado muda.
Meta: usar dados resolvidos no servidor como fonte inicial e manter updates incrementais e previsíveis no cliente com assinaturas eficientes.
Este artigo explica um padrão prático: aproveitar use em Server Components para pegar o estado inicial, e no cliente usar um store baseado em useSyncExternalStore que recebe updates via WebSocket/SSE ou similar. O objetivo é minimizar diferenças de hidratação e aproveitar Suspense/streaming de dados do servidor.
2. Abordagem ingênua vs abordagem otimizada
Abordagem ingênua
Uma abordagem comum é renderizar do servidor o HTML com dados X e, no cliente, logo após a montagem, disparar um fetch para obter os mesmos dados e popular o estado local (useEffect -> fetch -> setState). Isso causa:
duplication de requests;
piscadas na UI enquanto o fetch roda;
possíveis mismatches se o servidor e cliente obtiverem resultados diferentes.
Exemplo típico (ingênuo): Server Component injeta props, Client faz fetch no useEffect. Simples, mas ineficiente e propenso a inconsistência se houver atualizações frequentes.
Abordagem otimizada
A proposta otimizada combina três ideias:
Servidor resolve o estado inicial com
use(Server Component).Cliente cria um store com
useSyncExternalStoreque tem o estado inicial injetado como snapshot inicial.Assinatura do store escuta atualizações incrementais (WebSocket, SSE, EventSource) e notifica assinantes sem re-render global.
Benefícios:
Sem re-fetch imediato no cliente;
Hydration consistente porque o snapshot inicial é o mesmo usado na render server-side;
Assinaturas incrementais empurram apenas mudanças específicas para os componentes interessados;
Compatível com Suspense e streaming do servidor.
3. Implementação com código real
Overview da solução
A implmentação exemplo usa Next.js App Router (Server Components), um endpoint de WebSocket para updates e um client-store que usa useSyncExternalStore. O fluxo:
Server Component usa
use(fetchInitial())para resolver dados iniciais e rendeia um Client Component com props.Client Component inicializa o store com o snapshot inicial e liga o WebSocket para updates.
Componentes que precisam do estado usam um hook
useTodos()que consome o store viauseSyncExternalStore.
Server Component: fetch inicial com use
O bloco abaixo mostra um Server Component (Next.js App Router) que usa use para aguardar dados cacheáveis. Ele injeta o resultado como prop para o Client Component.
// app/(dashboard)/todos/page.server.tsx
import React, { use as reactUse } from 'react';
import TodosClient from './TodosClient';
import { fetchTodos } from '@/lib/data';
// fetchTodos retorna uma Promise que resolve um array de todos
export default function TodosPage() {
// use() é a API React para aguardar Promises em Server Components
const todos = reactUse(fetchTodos());
// Passamos o snapshot inicial para o cliente
return ;
}
O que faz: resolve todos no servidor e passa-os como prop. O que acontece depois: o cliente recebe exatamente esse snapshot durante a hidratação, evitando mismatch e fetch duplicado.
Client store: useSyncExternalStore com WebSocket
Agora vamos ao store cliente. Ele deve aceitar o snapshot inicial e expor funções para leitura e mutação local. A assinatura segue a API do useSyncExternalStore.
// src/stores/todosStore.ts
import { useEffect, useRef } from 'react';
type Todo = { id: string; text: string; done: boolean };
function createStore(initial: Todo[]) {
let state = initial.slice();
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot() {
return state;
}
function set(newState: Todo[]) {
state = newState;
listeners.forEach((l) => l());
}
function patch(patchFn: (s: Todo[]) => Todo[]) {
state = patchFn(state);
listeners.forEach((l) => l());
}
return { subscribe, getSnapshot, set, patch };
}
// Hook que instancia o store uma vez por mounting
export function useTodosStore(initial: Todo[]) {
const ref = useRef();
if (!ref.current) ref.current = createStore(initial);
return ref.current;
}
O que faz: cria um pequeno store com subscribe/getSnapshot/patch. O que acontece depois: os hooks consumidores podem conectar via useSyncExternalStore.
Hook consumidor: useTodos
Este hook encapsula a ligação entre o store e useSyncExternalStore. Ele também instala o listener do WebSocket (ou outro meio) para receber updates em tempo real.
// src/hooks/useTodos.ts
import { useEffect } from 'react';
import { useSyncExternalStore } from 'react';
import { useTodosStore } from '@/stores/todosStore';
export function useTodos(initialTodos: any[]) {
const store = useTodosStore(initialTodos);
// subscribe/getSnapshot para useSyncExternalStore
const todos = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
useEffect(() => {
// Exemplo de conexão WebSocket (browser)
const ws = new WebSocket('wss://example.com/todos-updates');
ws.addEventListener('message', (ev) => {
const payload = JSON.parse(ev.data);
// payload ex: { type: 'patch', changes: [...] }
if (payload.type === 'patch') {
store.patch((s) => {
// aplicar mudanças de forma determinística
const map = new Map(s.map((t) => [t.id, { ...t }]));
for (const c of payload.changes) {
map.set(c.id, { ...(map.get(c.id) || {}), ...c });
}
return Array.from(map.values());
});
}
});
return () => ws.close();
}, [store]);
return todos;
}
O que faz: liga o store a um WebSocket para receber patches. O que acontece depois: updates chegam e useSyncExternalStore garante re-render apenas dos componentes que leem esse snapshot.
Client Component usando o hook
Por fim, o Client Component usa o hook useTodos fornecendo o snapshot inicial vindo do servidor.
// app/(dashboard)/todos/TodosClient.tsx
'use client';
import React from 'react';
import { useTodos } from '@/hooks/useTodos';
export default function TodosClient({ initialTodos }: { initialTodos: any[] }) {
const todos = useTodos(initialTodos);
return (
{todos.map((t) => (
{t.text}
))}
);
}
O que faz: renderiza a lista com o snapshot inicial e mantém-se atualizada conforme o store muda. O que acontece depois: hidratação é consistente e updates são aplicados incrementalmente.
4. Testes e validação
Testes unitários do store
Testar o comportamento do store é direto. Basta instanciar o store com um snapshot, verificar getSnapshot, chamar patch e conferir notifications.
// tests/todosStore.test.ts
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { useTodosStore } from '@/stores/todosStore';
test('store notifica assinantes ao aplicar patch', () => {
const initial = [{ id: '1', text: 'a', done: false }];
const store = useTodosStore(initial);
let called = 0;
const unsub = store.subscribe(() => (called += 1));
act(() => {
store.patch((s) => s.map(t => t.id === '1' ? { ...t, done: true } : t));
});
expect(called).toBe(1);
expect(store.getSnapshot()[0].done).toBe(true);
unsub();
});
O que faz: valida que o store notifica subscritores e aplica patch corretamente. O que acontece depois: regressões no comportamento do store são detectadas.
Testes de integração: hidratação e atualizações
Para testar a integração server-client você pode usar um teste E2E leve ou simular a situação com @testing-library/react renderizando o Client Component com o snapshot inicial e simular mensagens do WebSocket com um mock.
Checklist de validação prática:
Render server-side produz HTML consistente com o snapshot;
Hidratação não produz warnings do React (mismatch / checksum);
Após receber update via WebSocket, apenas os componentes que dependem do snapshot re-renderizam;
Se houver latência na conexão, a UI permanece responsiva e previsível.
5. Considerações de produção
Erros comuns e armadilhas
Alguns erros frequentes ao aplicar esse padrão:
Ignorar o snapshot inicial: se o client sempre re-fetchar, você perde o benefício de hidratação consistente.
Mutar o snapshot vindo do servidor diretamente: sempre clone/retorne novo array/objetos no patch para evitar referências compartilhadas e re-renders imprevisíveis.
Assinaturas globais sem cleanup: vazamentos de WebSocket ou listeners podem causar comportamento estranho em SPA navegando entre rotas.
Conflitos entre updates locais e remotos: trate conflitos com regras claras (last-write-wins, CRDTs ou versionamento simples).
Dica: padronize mensagens de update como patches (operações) em vez de `state: full`. Patches são menores e fáceis de aplicar incrementalmente.
Performance e escopo de re-render
useSyncExternalStore foi desenhado para otimizar assinaturas externas. Se o store expõe o snapshot completo (uma lista grande), componentes que só precisam de um item ainda re-renderizarão quando qualquer item mudar. Estratégias para mitigar:
Fragmentar o store: expor getters mais granulares (getItemById) e hooks especializados.
Normalizar dados (map por id) e retornar apenas referências necessárias.
Memoizar seletores no hook consumidor.
Exemplo de selector (esquemático):
// uso simplificado: retornar sub-snapshot para reduzir re-render
function useTodoById(store, id) {
return useSyncExternalStore(
(cb) => store.subscribe(cb),
() => store.getSnapshot().find((t) => t.id === id),
() => store.getSnapshot().find((t) => t.id === id)
);
}
O que faz: conecta apenas a fatia do estado necessária. O que acontece depois: mudanas em outros itens não disparam re-render desse consumidor.
Compatibilidade e fallback
Se você ainda precisa rodar em ambientes sem Server Components, a estratégia pode ser adaptada. Em SSR tradicional, inclua o snapshot inicial como JSON inline em um script e faça o client-store inicializar a partir desse JSON. A ideia permanece: usar o snapshot inicial para evitar re-fetch imediato.
Conclusão prática
Separar responsabilidade entre Server Components (fonte de verdade inicial) e Client Components (assinaturas e interatividade) resolve problemas de hidratação, reduz requests duplicados e melhora performance quando combinado com useSyncExternalStore. O padrão proposto:
Use
useem Server Components para obter snapshot inicial confiável;Inicialize um store no cliente com esse snapshot e exponha
subscribeegetSnapshot;Use
useSyncExternalStorenos hooks consumidores e aplique updates incrementais via WebSocket/SSE;Fragmentize o store e use seletores para limitar re-renders.
Implementações reais exigirão cuidados: clonar estado, limpar assinaturas, tratar conflitos e otimizar seletores. Mantendo essas práticas você terá uma experiência consistente na hidratação, controle fino sobre re-renders e um caminho claro para integrar streaming e updates em tempo real.
Checklist final de produção:
Garantir snapshot inicial idêntico entre server e client.
Evitar fetchs redundantes no cliente.
Limpar conexões/assinaturas ao desmontar.
Normalizar dados para seletores eficientes.
Testar com cargas reais para validar perf.
Esses passos transformam um problema recorrente em um padrão previsível e testável. Aplique, meça e adapte: será mais fácil manter coerência entre server e client sem sacrificar performance.
Assuntos: React, server-components, usesyncexternalstore, hooks, Performance
Leia também
Suavizando transições em listas React com startTransition, useDeferredValue e Suspense
Como evitar travamentos em listas React: use startTransition para priorizar atualizações, useDeferredValue para adiar derivação de valores, e Suspense para…
useEffect vs useLayoutEffect no React: quando usar, exemplos e armadilhas
Compare useEffect e useLayoutEffect com exemplos práticos: quando usar cada um, medição de DOM sem layout shift, armadilhas comuns e padrões seguros para…
Context selectors: reduzir re-renders e otimizar estado global no React
Context selectors reduzem re-renders no React ao permitir que componentes leiam apenas fatias do estado global. Este artigo mostra quando usar, compara…
Criando useStableCallback (useEvent): callbacks estáveis no React
Aprenda a criar useStableCallback, um hook que fornece callbacks com identidade estável mas que chamam a versão mais recente do handler. Mostro o erro comum…
Melhorar LCP com React Server Components e Suspense: streaming, placeholders e revalidação
Reduza LCP com React Server Components e Suspense: prioridades de dados, streaming incremental, placeholders que preservam layout e revalidação de cache…
Sincronizando estado entre abas com BroadcastChannel e useSyncExternalStore
Aprenda a sincronizar estado entre abas no browser usando BroadcastChannel e useSyncExternalStore do React. Guia passo a passo, exemplo funcional de contador…