12k
All articles

Gestión de Estado en Svelte 5 con Runes

Gestión de estado en Svelte 5 con runes: usa $state, $derived y $effect, comparte estado entre componentes y evita fugas SSR en SvelteKit.

OpenReplay Team
OpenReplay Team
Gestión de Estado en Svelte 5 con Runes

Las runes son directivas del compilador — no importaciones en tiempo de ejecución — que hacen que la reactividad sea explícita y portable: $state declara una celda reactiva, $derived calcula un valor a partir de ella, y $effect reacciona a los cambios — reemplazando en conjunto las declaraciones implícitas $: y los writable stores de Svelte 4 en los tres roles. La documentación de Svelte describe las runes como palabras clave que el compilador reconoce, razón por la cual no es posible crear alias de ellas, importarlas ni invocarlas condicionalmente. Conectar $state a un único componente es la parte sencilla. La parte difícil — la que falla en producción — es estructurar el estado que comparten varios componentes sin perder reactividad, y delimitar correctamente ese estado cuando SvelteKit renderiza en el servidor.

Este artículo aborda la gestión de estado como una columna vertebral: estado local con $state, estado computado con $derived, efectos secundarios con $effect, y luego la parte que la mayoría de las guías omiten — compartir estado entre archivos mediante módulos .svelte.ts, delimitarlo por solicitud para SSR, y una regla de decisión para elegir la herramienta adecuada en cada paso. El hilo conductor: la reactividad en Svelte 5 es opt-in por valor, y casi todas las trampas provienen del punto donde el proxy se detiene — las instancias de clase, los bindings desestructurados y las exportaciones let de ESM quedan fuera del límite del proxy. Las versiones referenciadas aquí corresponden a Svelte 5.x y SvelteKit 2.x.

Puntos Clave

  • Cualquier valor que pueda expresarse como una función pura del estado existente pertenece a $derived, no a $effect; usar $effect para sincronizar un fragmento de estado a partir de otro es el uso incorrecto más frecuente de esta rune.
  • Exportar un $state reasignable desde un módulo .svelte.ts genera el error de compilación state_invalid_export; las dos soluciones aprobadas son exportar una función que retorne el estado, o exportar un objeto const o una instancia de clase y mutar sus propiedades.
  • El patrón recomendado para el estado global del cliente es una clase con campos $state exportada como una instancia const — el binding const nunca se reasigna, por lo que el problema de state_invalid_export desaparece.
  • Un módulo .svelte.ts que declara $state en el nivel superior crea una única instancia compartida por proceso del servidor, lo que provoca fugas de estado entre usuarios en SSR con SvelteKit; delimite el estado por solicitud retornándolo desde load y compartiéndolo mediante setContext/getContext.
  • Desestructurar un objeto $state captura el valor en el momento de la desestructuración, no un binding reactivo — lea a través del proxy o pase un getter para preservar la reactividad.

¿Qué es $state y cómo funciona el estado reactivo local?

$state declara una celda reactiva cuyas lecturas registran dependencias y cuyas escrituras programan actualizaciones; para tipos primitivos se asigna y reasigna con normalidad, y para objetos y arrays Svelte devuelve un proxy profundo de modo que las mutaciones directas quedan registradas. Según la documentación de $state, pasar un objeto plano o un array lo hace profundamente reactivo — se muta en su lugar y la interfaz dependiente se actualiza.

<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 }); // registrado
  }

  function toggle(id: number) {
    const todo = todos.find((t) => t.id === id);
    if (todo) todo.done = !todo.done; // la asignación de propiedad queda registrada
  }
</script>

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

En un objeto o array con $state profundamente reactivo, tanto push() como la asignación directa de propiedades funcionan porque el proxy intercepta las mutaciones a cualquier profundidad. La documentación señala un límite: la proxificación se detiene en las instancias de clase. Un objeto plano o un array se vuelve profundamente reactivo; una instancia de clase no, a menos que sus campos estén declarados con $state.

Para datos grandes y planos que se reemplazan por completo en lugar de mutarse, utilice $state.raw, que solo registra la reasignación — no la mutación.

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

  // payload.foo = 'x';  // sin actualización — la mutación no se registra
  payload = { ...payload, foo: 'x' }; // la actualización se dispara — la reasignación sí se registra
</script>

$state.raw omite la creación del proxy, evitando así la sobrecarga de descriptores por propiedad que implica el proxying profundo en objetos planos de gran tamaño. Recurra a él cuando los valores sean grandes, efectivamente inmutables y se reemplacen como una unidad — respuestas JSON parseadas, blobs de configuración, tablas de consulta. En cualquier otro caso, use $state por defecto.

¿Cómo gestionan el estado computado $derived y $derived.by?

$derived declara un valor calculado a partir del estado reactivo que se actualiza automáticamente cuando cambian sus dependencias, reemplazando las declaraciones reactivas $: de Svelte 4. Según la documentación de $derived, se recalcula a partir de sus dependencias en la siguiente lectura tras cualquier cambio en ellas. Desde Svelte 5.25, un derived no declarado como const también puede reasignarse temporalmente para sobreescribir su valor — útil para UI optimista — tras lo cual revierte a su valor calculado cuando alguna dependencia cambia.

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

Para cálculos que requieren bucles, condicionales o múltiples sentencias, use $derived.by, que acepta una función en lugar de una expresión:

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

La regla que previene la mayoría de los errores de reactividad: cualquier valor que pueda expresarse como una función pura del estado existente pertenece a $derived, no a $effect. Usar $effect para sincronizar un fragmento de estado a partir de otro introduce un ciclo reactivo redundante y es el uso incorrecto más frecuente de esta rune. Si se encuentra escribiendo un efecto que establece estado a partir de otro estado, reemplácelo con $derived.

¿Cuándo se debe usar $effect?

$effect es exclusivamente para efectos secundarios — suscripciones, logging, manipulación manual del DOM y persistencia — nunca para derivar valores. Según la documentación de $effect, los efectos se ejecutan después de que el DOM se actualiza, rastrean automáticamente los valores reactivos leídos en su interior, se vuelven a ejecutar cuando esos valores cambian, y se ejecutan únicamente en el navegador — nunca durante SSR. No existe un array de dependencias; Svelte detecta las dependencias a partir de lo que se lee.

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

  $effect(() => {
    const id = setInterval(() => console.log('tick', count), 1000);
    return () => clearInterval(id); // la limpieza se ejecuta antes de cada re-ejecución y al destruir el componente
  });
</script>

La función retornada es un callback de limpieza — se ejecuta antes de que el efecto se vuelva a ejecutar y cuando el componente es destruido, que es donde se desmonta intervalos, listeners y suscripciones. Dado que los efectos omiten completamente el SSR, no dependa de ellos para producir salida renderizada en el servidor; ese trabajo corresponde a las funciones load o a $derived.

El único uso incorrecto que vale la pena memorizar: no use $effect para copiar estado. Escribir $effect(() => { fullName = `${first} ${last}` }) crea un bucle de escritura de retorno donde un $derived sería a la vez correcto y más simple:

<script lang="ts">
  let first = $state('Ada');
  let last = $state('Lovelace');
  let fullName = $derived(`${first} ${last}`); // correcto — no se necesita ningún efecto
</script>

¿Cómo se comparte estado entre componentes en Svelte 5?

Para compartir estado reactivo entre archivos, colóquelo en un módulo .svelte.ts — pero no es posible exportar directamente un binding $state reasignable. El patrón intuitivo falla:

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

Exportar un binding $state reasignable desde un módulo de esta forma genera el error de compilación documentado state_invalid_export, con el mensaje 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.” El desencadenante es la reasignación (count += 1), no la importación en sí — el error se produce porque el $state exportado se reasigna, algo que una exportación let de ESM no puede hacer de forma segura a través del límite del módulo. Existen dos soluciones aprobadas.

Solución 1 — exportar un objeto const y mutar sus propiedades. El binding nunca se reasigna; solo cambian sus propiedades, por lo que cada importador lee el mismo proxy.

// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
  counter.count += 1; // mutación de propiedad, no rebinding
}

Solución 2 — mantener el estado local al archivo y exportar getters. Esto mantiene la celda privada y evita la reasignación externa.

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

El patrón recomendado para el estado global del cliente es la versión más limpia de la Solución 1: una clase con campos $state exportada como una instancia const. El binding const nunca se reasigna, por lo que el problema de state_invalid_export desaparece, y cada componente que importe la instancia leerá desde el mismo proxy reactivo.

// 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();

Cualquier componente que importe todoStore leerá y mutará la misma instancia reactiva — sin necesidad de pasar props manualmente ni de boilerplate de suscripciones. Nótese el parámetro de tipo $state<Todo[]>([]): TypeScript infiere los tipos a partir de los valores iniciales, pero se debe pasar un parámetro explícito para arrays vacíos, objetos vacíos o uniones más amplias que el valor inicial. Este patrón tiene una advertencia importante para SSR, que se aborda a continuación.

¿Cómo se comporta $state dentro de las clases?

$state funciona como un campo de clase, y el compilador transforma cada campo en un par de accessors get/set en el prototipo respaldados por una señal privada. La documentación de clases con $state describe esta transformación, y tiene dos consecuencias importantes: dado que los accessors residen en el prototipo y no en la instancia, Object.keys(instance) no lista los campos reactivos, y hacer spread con { ...instance } los omite.

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

La trampa que atrapa a todos es el binding de this en los manejadores de eventos. Pasar una referencia a un método lo desvincula de la instancia, y la documentación cubre este caso directamente:

<!-- this === el <button>, no la instancia de Todo — incorrecto -->
<button onclick={todo.toggle}>toggle</button>

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

Dado que las lecturas y escrituras atraviesan los accessors del prototipo, el método necesita el this correcto. Dos soluciones mantienen el binding correcto: envolver la llamada en una función flecha en el punto de uso (() => todo.toggle()), o definir el método como un campo de función flecha para que cierre sobre this:

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

Use la forma de campo flecha cuando tenga intención de pasar el método como referencia; use un método normal cuando siempre lo invoque como todo.toggle().

¿Cuáles son los principales errores de reactividad con las runes?

La mayoría de los errores con runes se remontan a una única regla: la reactividad es opt-in por valor, y el proxy se detiene en las instancias de clase, los bindings desestructurados y las exportaciones de ESM. Tres errores comunes explican la mayoría de las pérdidas de reactividad.

La desestructuración captura un valor, no un binding. Desestructurar un objeto $state lee el valor en el momento de la desestructuración; no crea una referencia reactiva.

const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.name se actualiza
console.log(name); // sigue siendo 'Ada' — capturado en el momento de la desestructuración

Para preservar la reactividad, lea a través del proxy original (user.name) o pase una función getter (() => user.name) que cierre sobre el proxy y vuelva a leer en cada llamada. Esta es la solución a aplicar cuando un valor “deja de actualizarse” después de refactorizarlo en una variable.

Los tipos nativos Map, Set, Date y URL son instancias de clase, por lo que el proxy se detiene en ellos. Use los equivalentes reactivos de svelte/reactivity, que rastrean lecturas en .size, .get(), .has() e iteración: SvelteMap, SvelteSet, SvelteDate, SvelteURL y SvelteURLSearchParams.

import { SvelteMap } from 'svelte/reactivity';

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

La documentación señala una advertencia: los valores almacenados dentro de un Map o Set reactivo no se vuelven profundamente reactivos. Si almacena un objeto plano en un SvelteMap y espera mutarlo de forma reactiva, envuélvalo primero con $state.

Los proxies no pueden cruzar ciertos límites de API. structuredClone, postMessage y algunos serializadores rechazan los proxies. Use $state.snapshot para obtener una copia plana y estática en el límite:

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

Use $state.snapshot únicamente en estos límites, no a lo largo de todo el código — el proxy es lo que se necesita en todos los demás casos.

¿Cuándo se debe usar estado global de módulo frente al contexto de Svelte?

El estado global de módulo es seguro para aplicaciones solo de cliente, pero inseguro para SSR; el estado delimitado por solicitud pertenece a una función load y se comparte a través de la API de contexto de Svelte. Un módulo .svelte.ts que declara $state en el nivel superior crea una única instancia compartida por proceso del servidor. En el cliente, eso es exactamente lo que se desea. En SSR con SvelteKit representa un riesgo de fuga entre solicitudes: la documentación de gestión de estado de SvelteKit indica que los servidores son de larga duración y se comparten entre usuarios, que no se deben almacenar datos por usuario en variables de módulo compartidas, y ofrece el ejemplo canónico de que el secreto de un usuario se filtre al render de otro.

La fuga tiene este aspecto — un store de módulo mutado durante un render del servidor es visible para la siguiente solicitud que llega al mismo proceso:

// src/lib/user.svelte.ts — PELIGROSO en SSR
export const currentUser = $state({ name: '' });
// +page.server.ts — establece estado compartido durante SSR
import { currentUser } from '$lib/user.svelte';

export function load({ locals }) {
  currentUser.name = locals.user.name; // se filtra entre solicitudes
}

La solución canónica es retornar los datos por solicitud desde load y compartirlos mediante setContext/getContext, que delimita el valor a un único árbol de componentes — y por tanto a una única solicitud — en lugar de a un módulo de alcance global al proceso.

// +layout.server.ts
export function load({ locals }) {
  return { user: locals.user }; // datos por solicitud
}
<!-- +layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  let { data } = $props();
  setContext('user', data.user); // delimitado al árbol de componentes de esta solicitud
</script>

En aplicaciones SvelteKit en producción, el estado de módulo con alcance incorrecto se manifiesta en las repeticiones de sesión como un usuario que ve brevemente los datos de otro usuario o valores obsoletos en la carga inicial — el síntoma visible de un estado que debería haber sido delimitado por solicitud retornando los datos por solicitud desde load y compartiéndolos mediante contexto, en lugar de mantenerlos en un módulo global. Las repeticiones de sesión de estas implementaciones revelan el error porque capturan el DOM renderizado inicialmente en el servidor, no solo el estado posterior a la hidratación.

La regla de decisión que une toda la columna vertebral:

Use $state local al componente para los valores que pertenecen a un único componente; $derived para cualquier cosa calculada a partir de ese estado; un store de clase a nivel de módulo (export const store = new Store()) para el estado global del cliente; y datos por solicitud retornados desde load y compartidos mediante setContext/getContext para el estado delimitado por solicitud o SSR que no debe filtrarse entre usuarios.

El paso de props — $props, $bindable y el helper de depuración $inspect — queda fuera de esta columna vertebral de gestión de estado; trátelos como herramientas de interfaz de componentes y depuración, no como contenedores de estado.

Migración de los stores de Svelte 4 a las runes

La mayoría de los patrones de reactividad de Svelte 4 se mapean directamente a runes, incluido el caso de estado compartido que antes pertenecía a los writable stores. La tabla a continuación cubre las traducciones que los desarrolladores de nivel intermedio encuentran con más frecuencia; la guía de migración a Svelte 5 cubre la superficie completa.

Svelte 4Runes de Svelte 5
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 compartido entre componentesstore de clase exportado como export const store = new Store()
store suscrito en el layout SSR para datos por usuarioload retorna datos → setContext/getContext

Las dos últimas filas son las que la mayoría de las guías omiten: un writable store compartido en toda la aplicación se convierte en una instancia de clase exportada como const, y un store por usuario en un contexto SSR se convierte en datos por solicitud retornados desde load y distribuidos mediante contexto.

Las runes hacen explícito el modelo de reactividad, pero el valor real está en la arquitectura: mantenga el estado tan local como sea posible, promuévalo a un store de clase a nivel de módulo solo cuando varios componentes lo compartan genuinamente, y delimítelo mediante contexto en el momento en que SSR entre en juego. Audite sus módulos .svelte.ts en busca de $state en el nivel superior que participe en el renderizado del servidor — esa única comprobación detecta la clase de error de estado de mayor gravedad en SvelteKit.

Preguntas Frecuentes

¿Cuál es la diferencia entre $state.raw y $state en Svelte 5?

$state devuelve un proxy profundo que rastrea las mutaciones a cualquier profundidad, por lo que push() y la asignación de propiedades desencadenan actualizaciones; $state.raw omite la creación del proxy y rastrea únicamente la reasignación, lo que significa que la mutación en su lugar se ignora y se debe reemplazar el valor por completo para desencadenar una actualización. Use $state.raw para datos grandes y efectivamente inmutables que se reemplazan como una unidad, como respuestas JSON parseadas, blobs de configuración o tablas de consulta, donde importa evitar la sobrecarga del proxy por propiedad. Use $state por defecto en cualquier otro caso.

¿Por qué mi valor $state deja de actualizarse después de desestructurarlo?

Desestructurar un objeto $state lee el valor en el momento de la desestructuración y crea una variable plana no reactiva; no crea un binding reactivo al proxy. Después de const name = user.name, mutar posteriormente user.name actualiza el proxy pero deja el name desestructurado congelado en su valor original. Para mantener la reactividad, lea a través del proxy original con user.name en el punto de uso, o pase una función getter como () => user.name que cierre sobre el proxy y vuelva a leer en cada llamada.

¿Se ejecutan los callbacks de $effect durante SSR en SvelteKit?

No. Los callbacks de $effect se ejecutan únicamente en el navegador y nunca durante el renderizado en el servidor. Los efectos se ejecutan después de que el DOM se actualiza y se vuelven a ejecutar cuando cambian los valores reactivos que leen, lo que significa que no pueden contribuir al HTML renderizado en el servidor. No dependa de $effect para producir salida durante SSR; coloque ese trabajo en una función load o en $derived. Los efectos son para efectos secundarios exclusivos del navegador, como suscripciones, logging, manipulación manual del DOM, intervalos y persistencia, con un callback de limpieza opcional retornado.

¿Son profundamente reactivos los valores almacenados dentro de un SvelteMap o SvelteSet?

No. SvelteMap y SvelteSet de svelte/reactivity rastrean lecturas en .size, .get(), .has() e iteración, y reaccionan cuando se añaden o eliminan entradas, pero los valores almacenados en su interior no se vuelven profundamente reactivos. Si almacena un objeto plano en un SvelteMap y espera que mutar sus propiedades desencadene actualizaciones, envuelva primero ese objeto con $state para que el objeto interno se convierta en un proxy reactivo. La colección reactiva rastrea la estructura, no el estado interno de los valores almacenados arbitrarios.

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.