12k
All articles

Gerenciamento de Estado no Svelte 5 com Runes

Gestão de estado no Svelte 5 com runes: use $state, $derived e $effect, compartilhe estado entre componentes e evite vazamentos SSR no SvelteKit.

OpenReplay Team
OpenReplay Team
Gerenciamento de Estado no Svelte 5 com Runes

Runes são diretivas de compilador — não importações em tempo de execução — que tornam a reatividade explícita e portátil: $state declara uma célula reativa, $derived computa um valor a partir dela, e $effect reage a mudanças — substituindo juntos as declarações implícitas $: e os writable stores do Svelte 4 nos três papéis. A documentação do Svelte descreve runes como palavras-chave reconhecidas pelo compilador, razão pela qual não é possível criar aliases, importá-las ou chamá-las condicionalmente. Conectar $state a um único componente é a parte fácil. A parte difícil — a que quebra em produção — é estruturar o estado compartilhado entre vários componentes sem perder a reatividade, e delimitar esse estado corretamente quando o SvelteKit renderiza no servidor.

Este artigo trata o gerenciamento de estado como uma espinha dorsal: estado local com $state, estado computado com $derived, efeitos colaterais com $effect, e então a parte que a maioria dos guias ignora — compartilhamento de estado entre arquivos via módulos .svelte.ts, delimitação por requisição para SSR, e uma regra de decisão para escolher a ferramenta certa em cada etapa. O fio condutor: a reatividade no Svelte 5 é opt-in por valor, e quase todas as armadilhas surgem de onde o proxy para — instâncias de classes, bindings desestruturados e exports let de ESM ficam todos fora do limite do proxy. As versões referenciadas aqui têm como alvo o Svelte 5.x e o SvelteKit 2.x.

Principais Conclusões

  • Qualquer valor que possa ser expresso como uma função pura do estado existente pertence ao $derived, não ao $effect; usar $effect para sincronizar um trecho de estado a partir de outro é o uso incorreto mais comum da rune.
  • Exportar $state reatribuível de um módulo .svelte.ts aciona o erro de compilação state_invalid_export; as duas correções sancionadas são exportar uma função que retorna o estado, ou exportar um objeto const ou instância de classe e mutar suas propriedades.
  • O padrão recomendado para estado global do cliente é uma classe com campos $state exportada como uma instância const — o binding const nunca é reatribuído, portanto o problema com state_invalid_export desaparece.
  • Um módulo .svelte.ts que declara $state no nível superior cria uma única instância compartilhada por processo do servidor, o que vaza estado entre usuários no SSR do SvelteKit; delimite o estado por requisição retornando-o de load e compartilhando-o via setContext/getContext.
  • Desestruturar um objeto $state captura o valor no momento da desestruturação, não um binding reativo — leia através do proxy ou passe um getter para preservar a reatividade.

O que é $state e como funciona o estado reativo local?

$state declara uma célula reativa cujas leituras registram dependências e cujas escritas agendam atualizações; para primitivos você atribui e reatribui normalmente, e para objetos e arrays o Svelte retorna um proxy profundo para que mutações diretas sejam rastreadas. Conforme a documentação do $state, passar um objeto ou array simples o torna profundamente reativo — você o muta no lugar e a UI dependente é atualizada.

<script lang="ts">
  let count = $state(0);
  let todos = $state([{ id: 1, text: 'Learn runes', done: false }]);

  function addTodo() {
    todos.push({ id: Date.now(), text: 'New todo', done: false }); // rastreado
  }

  function toggle(id: number) {
    const todo = todos.find((t) => t.id === id);
    if (todo) todo.done = !todo.done; // atribuição de propriedade é rastreada
  }
</script>

<button onclick={() => count++}>Clicked {count} times</button>

Em um objeto ou array $state profundamente reativo, tanto push() quanto a atribuição direta de propriedades funcionam porque o proxy intercepta mutações em qualquer profundidade. A documentação aponta um limite: a proxificação para em instâncias de classes. Um objeto ou array simples se torna profundamente reativo; uma instância de classe não, a menos que seus campos sejam declarados com $state.

Para dados grandes e planos que você substitui integralmente em vez de mutar, use $state.raw, que rastreia apenas reatribuições — não mutações.

<script lang="ts">
  let payload = $state.raw(largeApiResponse);

  // payload.foo = 'x';  // sem atualização — mutação não é rastreada
  payload = { ...payload, foo: 'x' }; // atualização ocorre — reatribuição é rastreada
</script>

$state.raw ignora a criação do proxy, evitando assim o overhead de descritores por propriedade do proxy profundo em objetos planos grandes. Use-o quando os valores são grandes, efetivamente imutáveis e substituídos como uma unidade — respostas JSON parseadas, blobs de configuração, tabelas de lookup. Caso contrário, use $state por padrão.

Como $derived e $derived.by lidam com estado computado?

$derived declara um valor computado a partir do estado reativo que se atualiza automaticamente quando suas dependências mudam, substituindo as declarações reativas $: do Svelte 4. Conforme a documentação do $derived, ele é recalculado a partir de suas dependências quando lido pela próxima vez após qualquer uma delas mudar. Desde o Svelte 5.25, um derived não-const também pode ser temporariamente reatribuído para sobrescrever seu valor — útil para UI otimista — após o que reverte ao seu valor computado quando uma dependência mudar.

<script lang="ts">
  let count = $state(0);
  let doubled = $derived(count * 2);
  let isEven = $derived(count % 2 === 0);
</script>

Para computações que precisam de loops, condicionais ou múltiplas instruções, use $derived.by, que recebe uma função em vez de uma expressão:

<script lang="ts">
  let items = $state([{ price: 10, quantity: 2 }]);

  let summary = $derived.by(() => {
    let total = 0;
    let count = 0;
    for (const item of items) {
      total += item.price * item.quantity;
      count += item.quantity;
    }
    return { total, count };
  });
</script>

A regra que previne a maioria dos bugs de reatividade: qualquer valor que possa ser expresso como uma função pura do estado existente pertence ao $derived, não ao $effect. Usar $effect para sincronizar um trecho de estado a partir de outro introduz um ciclo reativo redundante e é o uso incorreto mais comum da rune. Se você se pegar escrevendo um effect que define estado a partir de outro estado, substitua-o por $derived.

Quando usar $effect?

$effect é exclusivamente para efeitos colaterais — assinaturas, logging, manipulação manual do DOM e persistência — nunca para derivar valores. Conforme a documentação do $effect, os effects são executados após o DOM ser atualizado, rastreiam automaticamente os valores reativos lidos em seu interior, são re-executados quando esses valores mudam, e são executados apenas no navegador — nunca durante o SSR. Não há array de dependências; o Svelte detecta as dependências a partir do que você lê.

<script lang="ts">
  let count = $state(0);

  $effect(() => {
    const id = setInterval(() => console.log('tick', count), 1000);
    return () => clearInterval(id); // cleanup é executado antes da re-execução e na destruição
  });
</script>

A função retornada é um callback de limpeza — ela é executada antes do effect ser re-executado e quando o componente é destruído, sendo onde você encerra intervalos, listeners e assinaturas. Como os effects ignoram completamente o SSR, não dependa deles para produzir saída renderizada no servidor; esse trabalho pertence às funções load ou ao $derived.

O único uso incorreto que vale memorizar: não use $effect para copiar estado. Escrever $effect(() => { fullName = `${first} ${last}` }) cria um loop de write-back onde um $derived seria ao mesmo tempo correto e mais simples:

<script lang="ts">
  let first = $state('Ada');
  let last = $state('Lovelace');
  let fullName = $derived(`${first} ${last}`); // correto — nenhum effect necessário
</script>

Como compartilhar estado entre componentes no Svelte 5?

Para compartilhar estado reativo entre arquivos, coloque-o em um módulo .svelte.ts — mas você não pode exportar um binding $state reatribuível diretamente. O padrão intuitivo falha:

// src/lib/counter.svelte.ts
export let count = $state(0);
export function increment() {
  count += 1;
}

Exportar um binding $state reatribuível de um módulo dessa forma aciona o erro de compilação documentado state_invalid_export, com a mensagem literal: “Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value’s properties.” O gatilho é a reatribuição (count += 1), não a importação em si — o erro ocorre porque o $state exportado é reatribuído, o que um export let de ESM não pode fazer de forma segura através do limite do módulo. Existem duas correções sancionadas.

Correção 1 — exportar um objeto const e mutar suas propriedades. O binding nunca é reatribuído; apenas suas propriedades mudam, então cada importador lê o mesmo proxy.

// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
  counter.count += 1; // mutação de propriedade, não rebinding
}

Correção 2 — manter o estado local ao arquivo e exportar getters. Isso mantém a célula privada e impede reatribuições externas.

// src/lib/counter.svelte.ts
let count = $state(0);
export function getCount() {
  return count;
}
export function increment() {
  count += 1;
}

O padrão recomendado para estado global do cliente é a versão mais limpa da Correção 1: uma classe com campos $state exportada como uma instância const. O binding const nunca é reatribuído, portanto o problema com state_invalid_export desaparece, e cada componente que importa a instância lê do mesmo proxy reativo.

// src/lib/todo-store.svelte.ts
class Todo {
  done = $state(false);
  text = $state('');
  constructor(text: string) {
    this.text = text;
  }
}

class TodoStore {
  items = $state<Todo[]>([]);
  filter = $state<'all' | 'active' | 'done'>('all');

  get visible(): Todo[] {
    if (this.filter === 'all') return this.items;
    const wantDone = this.filter === 'done';
    return this.items.filter((t) => t.done === wantDone);
  }

  add(text: string) {
    this.items.push(new Todo(text));
  }

  remove(todo: Todo) {
    this.items = this.items.filter((t) => t !== todo);
  }
}

export const todoStore = new TodoStore();

Qualquer componente que importar todoStore lê e muta a mesma instância reativa — sem necessidade de passar props manualmente, sem boilerplate de assinaturas. Observe o parâmetro de tipo $state<Todo[]>([]): o TypeScript infere tipos a partir dos inicializadores, mas você passa um parâmetro explícito para arrays vazios, objetos vazios ou uniões mais amplas do que o valor inicial. Este padrão tem uma ressalva importante para SSR, abordada a seguir.

Como $state se comporta dentro de classes?

$state funciona como um campo de classe, e o compilador transforma cada campo em um par de acessores get/set no protótipo, respaldado por um signal privado. A documentação de classes com $state descreve essa transformação, e ela tem duas consequências importantes: como os acessores residem no protótipo em vez da instância, Object.keys(instance) não lista os campos reativos, e o spread { ...instance } os omite.

class Todo {
  done = $state(false);
  text = $state('');
  constructor(text: string) {
    this.text = text;
  }
  toggle() {
    this.done = !this.done;
  }
}

A armadilha que pega a todos é o binding de this em event handlers. Passar uma referência de método desvincula-a da instância, e a documentação cobre esse caso diretamente:

<!-- this === o <button>, não a instância de Todo — quebrado -->
<button onclick={todo.toggle}>toggle</button>

<!-- this === todo — funciona -->
<button onclick={() => todo.toggle()}>toggle</button>

Como leituras e escritas atravessam os acessores do protótipo, o método precisa do this correto. Duas correções mantêm o binding: envolva a chamada em uma arrow function no ponto de uso (() => todo.toggle()), ou defina o método como um campo arrow function para que ele feche sobre this:

class Todo {
  done = $state(false);
  toggle = () => {
    this.done = !this.done; // campo arrow — `this` é vinculado permanentemente
  };
}

Use a forma de campo arrow quando pretender passar o método como referência; use um método normal quando sempre o chamar como todo.toggle().

Quais são as principais armadilhas de reatividade com runes?

A maioria dos bugs com runes remonta a uma única regra: a reatividade é opt-in por valor, e o proxy para em instâncias de classes, bindings desestruturados e exports de ESM. Três armadilhas respondem pela maioria dos casos de reatividade perdida.

Desestruturação captura um valor, não um binding. Desestruturar um objeto $state lê o valor no momento da desestruturação; não cria uma referência reativa.

const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.name é atualizado
console.log(name); // ainda 'Ada' — capturado no momento da desestruturação

Para preservar a reatividade, leia através do proxy original (user.name) ou passe uma função getter (() => user.name) que fecha sobre o proxy e relê a cada chamada. Esta é a correção a ser usada sempre que um valor “para de atualizar” após ser refatorado para uma variável.

Map, Set, Date e URL nativos são instâncias de classes, portanto o proxy para neles. Use os equivalentes reativos de svelte/reactivity, que rastreiam leituras em .size, .get(), .has() e iteração: SvelteMap, SvelteSet, SvelteDate, SvelteURL e SvelteURLSearchParams.

import { SvelteMap } from 'svelte/reactivity';

const cache = new SvelteMap<string, number>();
cache.set('a', 1); // reativo

A documentação aponta uma ressalva: valores armazenados dentro de um Map ou Set reativo não se tornam profundamente reativos. Se você armazenar um objeto simples em um SvelteMap e esperar que a mutação de suas propriedades acione atualizações, envolva esse objeto com $state primeiro.

Proxies não conseguem cruzar certas fronteiras de API. structuredClone, postMessage e alguns serializadores rejeitam proxies. Use $state.snapshot para obter uma cópia simples e estática na fronteira:

await fetch('/api/save', {
  method: 'POST',
  body: JSON.stringify($state.snapshot(user)),
});

Use $state.snapshot apenas nessas fronteiras, não em todo o seu código — o proxy é o que você quer em todos os outros lugares.

Quando usar estado global de módulo versus contexto do Svelte?

Estado global de módulo é seguro para aplicações client-only, mas inseguro para SSR; estado com escopo por requisição pertence a uma função load e é compartilhado através da API de contexto do Svelte. Um módulo .svelte.ts que declara $state no nível superior cria uma única instância compartilhada por processo do servidor. No cliente, isso é exatamente o que você quer. No SSR do SvelteKit, é um risco de vazamento entre requisições: a documentação de gerenciamento de estado do SvelteKit afirma que servidores são de longa duração e compartilhados entre usuários, que você não deve armazenar dados por usuário em variáveis de módulo compartilhadas, e fornece o exemplo canônico do segredo de um usuário vazando para a renderização de outro.

O vazamento se parece com isso — um store de módulo mutado durante uma renderização no servidor fica visível para a próxima requisição que atingir o mesmo processo:

// src/lib/user.svelte.ts — PERIGOSO no SSR
export const currentUser = $state({ name: '' });
// +page.server.ts — define estado compartilhado durante o SSR
import { currentUser } from '$lib/user.svelte';

export function load({ locals }) {
  currentUser.name = locals.user.name; // vaza entre requisições
}

A correção canônica é retornar dados por requisição de load e compartilhá-los via setContext/getContext, que delimita o valor a uma única árvore de componentes — e portanto a uma única requisição — em vez de um módulo de escopo global de processo.

// +layout.server.ts
export function load({ locals }) {
  return { user: locals.user }; // dados por requisição
}
<!-- +layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  let { data } = $props();
  setContext('user', data.user); // delimitado à árvore de componentes desta requisição
</script>

Em aplicações SvelteKit em produção, estado de nível de módulo com escopo incorreto aparece em session replays como um usuário vendo brevemente os dados de outro usuário ou valores desatualizados no carregamento inicial — o sintoma visível de estado que deveria ter sido delimitado por requisição, retornando dados por requisição de load e compartilhando-os via contexto, em vez de mantê-los em um módulo global. Session replays dessas implementações expõem o bug porque capturam o DOM renderizado inicialmente no servidor, não apenas o estado pós-hidratação.

A regra de decisão que une toda a espinha dorsal:

Use $state local ao componente para valores pertencentes a um único componente; $derived para qualquer coisa computada a partir desse estado; um store de classe no nível de módulo (export const store = new Store()) para estado global do cliente; e dados por requisição retornados de load e compartilhados via setContext/getContext para estado com escopo por requisição ou SSR que não deve vazar entre usuários.

O encadeamento de props — $props, $bindable e o helper de debug $inspect — fica fora desta espinha dorsal de gerenciamento de estado; trate-os como ferramentas de interface de componente e depuração, não como contêineres de estado.

Migrando de stores do Svelte 4 para runes

A maioria dos padrões de reatividade do Svelte 4 mapeia diretamente para runes, incluindo o caso de estado compartilhado que os writable stores costumavam gerenciar. A tabela abaixo cobre as traduções que desenvolvedores intermediários encontram com mais frequência; o guia de migração do Svelte 5 cobre a superfície completa.

Svelte 4Svelte 5 runes
let count = 0;let count = $state(0);
$: doubled = count * 2;let doubled = $derived(count * 2);
$: console.log(count);$effect(() => console.log(count));
export let name = 'world';let { name = 'world' } = $props();
const count = writable(0); $count++let count = $state(0); count++
store writable compartilhado entre componentesstore de classe exportado como export const store = new Store()
store assinado no layout SSR para dados por usuárioload retorna dados → setContext/getContext

As duas últimas linhas são as que a maioria dos guias omite: um writable store compartilhado em toda a aplicação se torna uma instância de classe exportada como const, e um store por usuário em um contexto SSR se torna dados por requisição retornados de load e encaminhados através do contexto.

Runes tornam o modelo de reatividade explícito, mas a vantagem está na arquitetura: mantenha o estado tão local quanto possível, promova-o para um store de classe no nível de módulo apenas quando vários componentes genuinamente o compartilham, e delimite-o através do contexto no momento em que o SSR entrar em cena. Audite seus módulos .svelte.ts em busca de $state de nível superior que participe da renderização no servidor — essa única verificação captura a classe de bugs de estado de maior severidade no SvelteKit.

Perguntas Frequentes

Qual é a diferença entre $state.raw e $state no Svelte 5?

$state retorna um proxy profundo que rastreia mutações em qualquer profundidade, portanto push() e atribuição de propriedades acionam atualizações; $state.raw ignora a criação do proxy e rastreia apenas reatribuições, o que significa que mutações no lugar são ignoradas e você deve substituir o valor integralmente para acionar uma atualização. Use $state.raw para dados grandes e efetivamente imutáveis substituídos como uma unidade, como respostas JSON parseadas, blobs de configuração ou tabelas de lookup, onde ignorar o overhead de proxy por propriedade é relevante. Use $state por padrão nos demais casos.

Por que meu valor $state para de atualizar após ser desestruturado?

Desestruturar um objeto $state lê o valor no momento da desestruturação e cria uma variável simples e não reativa; não cria um binding reativo ao proxy. Após const name = user.name, mutar user.name posteriormente atualiza o proxy, mas deixa o name desestruturado congelado em seu valor original. Para manter a reatividade, leia através do proxy original com user.name no ponto de uso, ou passe uma função getter como () => user.name que fecha sobre o proxy e relê a cada chamada.

Os callbacks de $effect são executados durante o SSR no SvelteKit?

Não. Os callbacks de $effect são executados apenas no navegador e nunca durante a renderização no lado do servidor. Os effects são executados após o DOM ser atualizado e re-executados quando os valores reativos que leem mudam, o que significa que não podem contribuir para o HTML renderizado no servidor. Não dependa de $effect para produzir saída durante o SSR; coloque esse trabalho em uma função load ou em $derived. Effects são para efeitos colaterais exclusivos do navegador, como assinaturas, logging, manipulação manual do DOM, intervalos e persistência, com um callback de limpeza opcional retornado.

Os valores armazenados dentro de um SvelteMap ou SvelteSet são profundamente reativos?

Não. SvelteMap e SvelteSet de svelte/reactivity rastreiam leituras em .size, .get(), .has() e iteração, e reagem a entradas sendo adicionadas ou removidas, mas os valores armazenados em seu interior não se tornam profundamente reativos. Se você armazenar um objeto simples em um SvelteMap e esperar que a mutação de suas propriedades acione atualizações, envolva esse objeto com $state primeiro para que o objeto interno se torne um proxy reativo. A coleção reativa rastreia a estrutura, não o estado interno de valores armazenados arbitrários.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.