Destino Dev

Blog

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:

  1. Servidor resolve o estado inicial com use (Server Component).

  2. Cliente cria um store com useSyncExternalStore que tem o estado inicial injetado como snapshot inicial.

  3. 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:

  1. Server Component usa use(fetchInitial()) para resolver dados iniciais e rendeia um Client Component com props.

  2. Client Component inicializa o store com o snapshot inicial e liga o WebSocket para updates.

  3. Componentes que precisam do estado usam um hook useTodos() que consome o store via useSyncExternalStore.

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 use em Server Components para obter snapshot inicial confiável;

  • Inicialize um store no cliente com esse snapshot e exponha subscribe e getSnapshot;

  • Use useSyncExternalStore nos 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:

  1. Garantir snapshot inicial idêntico entre server e client.

  2. Evitar fetchs redundantes no cliente.

  3. Limpar conexões/assinaturas ao desmontar.

  4. Normalizar dados para seletores eficientes.

  5. 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