12k
All articles

Управление состоянием в Svelte 5 с помощью Runes

Управление состоянием в Svelte 5 с рунами: $state, $derived и $effect, общий state между компонентами и защита от SSR-утечек в SvelteKit.

OpenReplay Team
OpenReplay Team
Управление состоянием в Svelte 5 с помощью Runes

Runes — это директивы компилятора, а не runtime-импорты, которые делают реактивность явной и переносимой: $state объявляет реактивную ячейку, $derived вычисляет значение на её основе, а $effect реагирует на изменения — вместе они заменяют неявные объявления $: и writable stores из Svelte 4 во всех трёх ролях. В документации Svelte runes описываются как ключевые слова, распознаваемые компилятором, — именно поэтому их нельзя создавать псевдонимы, импортировать или вызывать условно. Подключить $state к отдельному компоненту несложно. Сложнее — и именно здесь всё ломается в продакшене — структурировать состояние, которое разделяют несколько компонентов, не теряя реактивности, и правильно ограничивать область видимости этого состояния при серверном рендеринге в SvelteKit.

В этой статье управление состоянием рассматривается как единая цепочка: локальное состояние через $state, вычисляемое состояние через $derived, побочные эффекты через $effect, а затем то, что большинство руководств пропускают — совместное использование состояния между файлами через модули .svelte.ts, его ограничение областью видимости на уровне запроса для SSR и правило выбора подходящего инструмента на каждом этапе. Сквозная идея: реактивность в Svelte 5 подключается явно для каждого значения, и почти все ловушки связаны с тем, где заканчивается прокси — экземпляры классов, деструктурированные привязки и ESM-экспорты через let находятся за пределами границы прокси. Версии, упоминаемые в статье, соответствуют Svelte 5.x и SvelteKit 2.x.

Ключевые выводы

  • Любое значение, которое можно выразить как чистую функцию от существующего состояния, должно находиться в $derived, а не в $effect; использование $effect для синхронизации одного фрагмента состояния с другим — наиболее распространённое злоупотребление этой руной.
  • Экспорт переназначаемого $state из модуля .svelte.ts вызывает ошибку компилятора state_invalid_export; два санкционированных способа исправления — экспортировать функцию, возвращающую состояние, или экспортировать объект const либо экземпляр класса и изменять его свойства.
  • Рекомендуемый паттерн для общего клиентского состояния приложения — класс с полями $state, экспортируемый как экземпляр const: привязка const никогда не переназначается, поэтому проблема state_invalid_export исчезает.
  • Модуль .svelte.ts, объявляющий $state на верхнем уровне, создаёт один общий экземпляр на серверный процесс, что приводит к утечке состояния между пользователями при SSR в SvelteKit; ограничивайте область видимости состояния на уровне запроса, возвращая его из load и передавая через setContext/getContext.
  • Деструктуризация объекта $state захватывает значение в момент деструктуризации, а не реактивную привязку — читайте через прокси или передавайте геттер для сохранения реактивности.

Что такое $state и как работает локальное реактивное состояние?

$state объявляет реактивную ячейку, чтение которой регистрирует зависимости, а запись планирует обновления; для примитивов вы присваиваете и переназначаете значения обычным образом, а для объектов и массивов Svelte возвращает глубокий прокси, благодаря которому прямые мутации отслеживаются. Согласно документации $state, передача обычного объекта или массива делает его глубоко реактивным — вы изменяете его на месте, и зависимый UI обновляется.

<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 }); // отслеживается
  }

  function toggle(id: number) {
    const todo = todos.find((t) => t.id === id);
    if (todo) todo.done = !todo.done; // присваивание свойства отслеживается
  }
</script>

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

На глубоко реактивном объекте или массиве $state и push(), и прямое присваивание свойств работают, поскольку прокси перехватывает мутации на любой глубине. В документации отмечена одна граница: проксификация останавливается на экземплярах классов. Обычный объект или массив становится глубоко реактивным; экземпляр класса — нет, если только его поля сами не объявлены через $state.

Для больших плоских данных, которые вы заменяете целиком, а не мутируете, используйте $state.raw — он отслеживает только переназначение, но не мутацию.

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

  // payload.foo = 'x';  // обновления нет — мутация не отслеживается
  payload = { ...payload, foo: 'x' }; // обновление срабатывает — переназначение отслеживается
</script>

$state.raw пропускает создание прокси, что позволяет избежать накладных расходов на дескрипторы свойств при глубоком проксировании больших плоских объектов. Используйте его, когда значения большие, фактически неизменяемые и заменяются целиком — например, распарсенные JSON-ответы, конфигурационные блобы, справочные таблицы. В остальных случаях используйте $state.

Как $derived и $derived.by работают с вычисляемым состоянием?

$derived объявляет значение, вычисляемое из реактивного состояния и автоматически обновляемое при изменении зависимостей, заменяя реактивные объявления $: из Svelte 4. Согласно документации $derived, значение пересчитывается из зависимостей при следующем чтении после изменения любой из них. Начиная со Svelte 5.25, не-const derived также можно временно переназначить для переопределения значения — это удобно для оптимистичного UI — после чего оно возвращается к вычисленному значению при следующем изменении зависимости.

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

Для вычислений, требующих циклов, условий или нескольких операторов, используйте $derived.by, который принимает функцию вместо выражения:

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

Правило, предотвращающее большинство ошибок реактивности: любое значение, которое можно выразить как чистую функцию от существующего состояния, должно находиться в $derived, а не в $effect. Использование $effect для синхронизации одного фрагмента состояния с другим создаёт избыточный реактивный цикл и является наиболее распространённым злоупотреблением руной. Если вы обнаруживаете, что пишете эффект, устанавливающий состояние из другого состояния, — замените его на $derived.

Когда следует использовать $effect?

$effect предназначен исключительно для побочных эффектов — подписок, логирования, ручной работы с DOM и персистентности — но не для получения значений. Согласно документации $effect, эффекты выполняются после обновления DOM, автоматически отслеживают реактивные значения, прочитанные внутри них, повторно запускаются при их изменении и работают только в браузере — никогда во время SSR. Массива зависимостей нет; Svelte определяет зависимости по тому, что вы читаете.

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

  $effect(() => {
    const id = setInterval(() => console.log('tick', count), 1000);
    return () => clearInterval(id); // очистка выполняется перед повторным запуском и при уничтожении
  });
</script>

Возвращаемая функция является колбэком очистки — она выполняется перед повторным запуском эффекта и при уничтожении компонента; именно здесь вы завершаете интервалы, слушатели и подписки. Поскольку эффекты полностью пропускают SSR, не полагайтесь на них для формирования серверно-рендеренного вывода; эта работа относится к функциям load или $derived.

Единственное злоупотребление, которое стоит запомнить: не используйте $effect для копирования состояния. Написание $effect(() => { fullName = `${first} ${last}` }) создаёт цикл обратной записи, тогда как $derived был бы одновременно правильным и более простым решением:

<script lang="ts">
  let first = $state('Ada');
  let last = $state('Lovelace');
  let fullName = $derived(`${first} ${last}`); // правильно — эффект не нужен
</script>

Как организовать совместное использование состояния между компонентами в Svelte 5?

Для совместного использования реактивного состояния между файлами поместите его в модуль .svelte.ts — однако напрямую экспортировать переназначаемую привязку $state нельзя. Интуитивный паттерн не работает:

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

Экспорт переназначаемой привязки $state из модуля подобным образом вызывает задокументированную ошибку компилятора state_invalid_export с сообщением: “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.” Причина ошибки — переназначение (count += 1), а не сам импорт: ошибка возникает потому, что экспортируемый $state переназначается, а ESM-экспорт через let не может делать это безопасно через границу модуля. Существует два санкционированных способа исправления.

Способ 1 — экспортировать объект const и изменять его свойства. Привязка никогда не переназначается; изменяются только её свойства, поэтому каждый импортёр читает один и тот же прокси.

// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
  counter.count += 1; // мутация свойства, а не перепривязка
}

Способ 2 — сохранить состояние локальным для файла и экспортировать геттеры. Это делает ячейку приватной и предотвращает внешнее переназначение.

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

Рекомендуемый паттерн для общего клиентского состояния приложения — наиболее чистая версия Способа 1: класс с полями $state, экспортируемый как экземпляр const. Привязка const никогда не переназначается, поэтому проблема state_invalid_export исчезает, а каждый компонент, импортирующий экземпляр, читает из одного реактивного прокси.

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

Любой компонент, импортирующий todoStore, читает и изменяет один и тот же реактивный экземпляр — без передачи через props, без шаблонного кода подписок. Обратите внимание на параметр типа $state<Todo[]>([]): TypeScript выводит типы из инициализаторов, но для пустых массивов, пустых объектов или union-типов, более широких, чем начальное значение, необходимо передавать явный параметр. Этот паттерн имеет одну важную оговорку для SSR, которая рассматривается ниже.

Как $state работает внутри классов?

$state работает как поле класса, и компилятор преобразует каждое поле в пару get/set-аксессоров на прототипе, поддерживаемых приватным сигналом. Документация по классам $state описывает это преобразование, и оно имеет два следствия, которые стоит знать: поскольку аксессоры находятся на прототипе, а не на экземпляре, Object.keys(instance) не перечисляет реактивные поля, а spread-оператор { ...instance } их опускает.

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

Ловушка, с которой сталкиваются все, — привязка this в обработчиках событий. Передача ссылки на метод отвязывает её от экземпляра, и документация рассматривает этот случай напрямую:

<!-- this === <button>, а не экземпляр Todo — не работает -->
<button onclick={todo.toggle}>toggle</button>

<!-- this === todo — работает -->
<button onclick={() => todo.toggle()}>toggle</button>

Поскольку чтение и запись проходят через аксессоры прототипа, методу нужен правильный this. Два способа сохранить привязку: обернуть вызов в стрелочную функцию на месте вызова (() => todo.toggle()), или определить метод как поле со стрелочной функцией, чтобы оно замыкалось на this:

class Todo {
  done = $state(false);
  toggle = () => {
    this.done = !this.done; // поле со стрелочной функцией — `this` привязан постоянно
  };
}

Используйте форму с полем-стрелочной функцией, когда планируете передавать метод как ссылку; используйте обычный метод, когда всегда вызываете его как todo.toggle().

Каковы основные подводные камни реактивности при работе с рунами?

Большинство ошибок с рунами сводятся к одному правилу: реактивность подключается явно для каждого значения, и прокси останавливается на экземплярах классов, деструктурированных привязках и ESM-экспортах. Три подводных камня составляют большинство случаев потери реактивности.

Деструктуризация захватывает значение, а не привязку. Деструктуризация объекта $state читает значение в момент деструктуризации; она не создаёт реактивную ссылку.

const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.name обновляется
console.log(name); // всё ещё 'Ada' — захвачено в момент деструктуризации

Для сохранения реактивности читайте через исходный прокси (user.name) или передавайте геттер-функцию (() => user.name), которая замыкается на прокси и перечитывает значение при каждом вызове. Это решение стоит применять всякий раз, когда значение «перестаёт обновляться» после рефакторинга в переменную.

Нативные Map, Set, Date и URL являются экземплярами классов, поэтому прокси останавливается на них. Используйте реактивные аналоги из svelte/reactivity, которые отслеживают чтения .size, .get(), .has() и итерацию: SvelteMap, SvelteSet, SvelteDate, SvelteURL и SvelteURLSearchParams.

import { SvelteMap } from 'svelte/reactivity';

const cache = new SvelteMap<string, number>();
cache.set('a', 1); // реактивно

В документации отмечена одна оговорка: значения, хранящиеся внутри реактивного Map или Set, не становятся глубоко реактивными. Если вы храните обычный объект в SvelteMap и ожидаете реактивного отслеживания мутаций его свойств, сначала оберните этот объект в $state.

Прокси не могут пересекать определённые границы API. structuredClone, postMessage и некоторые сериализаторы отклоняют прокси. Используйте $state.snapshot для получения обычной статической копии на границе:

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

Используйте $state.snapshot только на этих границах, а не повсеместно в коде — прокси — это именно то, что вам нужно во всех остальных случаях.

Когда использовать глобальное состояние модуля, а когда — Svelte context?

Глобальное состояние модуля безопасно для клиентских приложений, но небезопасно для SSR; состояние, ограниченное запросом, должно находиться в функции load и передаваться через context API Svelte. Модуль .svelte.ts, объявляющий $state на верхнем уровне, создаёт один общий экземпляр на серверный процесс. На клиенте это именно то, что нужно. При SSR в SvelteKit это риск утечки между запросами: документация SvelteKit по управлению состоянием указывает, что серверы долгоживущие и общие для всех пользователей, что нельзя хранить данные конкретного пользователя в общих переменных модуля, и приводит канонический пример утечки секрета одного пользователя в рендер другого.

Утечка выглядит следующим образом — состояние модуля, изменённое во время серверного рендеринга, становится видимым для следующего запроса, обрабатываемого тем же процессом:

// src/lib/user.svelte.ts — ОПАСНО при SSR
export const currentUser = $state({ name: '' });
// +page.server.ts — устанавливает общее состояние во время SSR
import { currentUser } from '$lib/user.svelte';

export function load({ locals }) {
  currentUser.name = locals.user.name; // утекает между запросами
}

Канонический способ исправления — возвращать данные конкретного запроса из load и передавать их через setContext/getContext, что ограничивает значение единственным деревом компонентов — и, следовательно, единственным запросом — вместо модуля уровня процесса.

// +layout.server.ts
export function load({ locals }) {
  return { user: locals.user }; // данные конкретного запроса
}
<!-- +layout.svelte -->
<script lang="ts">
  import { setContext } from 'svelte';
  let { data } = $props();
  setContext('user', data.user); // ограничено деревом компонентов текущего запроса
</script>

В продакшен-приложениях SvelteKit неправильно ограниченное состояние на уровне модуля проявляется в записях сессий как кратковременное отображение данных другого пользователя или устаревших значений при начальной загрузке — видимый симптом состояния, которое должно было быть ограничено запросом путём возврата данных из load и передачи через context, а не хранения в глобальной переменной модуля. Записи сессий таких реализаций выявляют ошибку, поскольку захватывают исходный серверно-рендеренный DOM, а не только состояние после гидратации.

Правило выбора, объединяющее всю цепочку:

Используйте компонентно-локальный $state для значений, принадлежащих одному компоненту; $derived — для всего, что вычисляется из этого состояния; хранилище на основе класса уровня модуля (export const store = new Store()) — для общего клиентского состояния приложения; и данные конкретного запроса, возвращаемые из load и передаваемые через setContext/getContext — для состояния, ограниченного запросом или SSR, которое не должно утекать между пользователями.

Передача через props — $props, $bindable и вспомогательный инструмент отладки $inspect — находится за пределами этой цепочки управления состоянием; рассматривайте их как инструменты интерфейса компонентов и отладки, а не как контейнеры состояния.

Миграция со stores Svelte 4 на runes

Большинство паттернов реактивности Svelte 4 напрямую отображаются на runes, включая случай совместного состояния, которым раньше владели writable stores. Таблица ниже охватывает переводы, с которыми чаще всего сталкиваются разработчики среднего уровня; руководство по миграции на Svelte 5 охватывает всю поверхность изменений.

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++
Writable store, общий для компонентовХранилище-класс, экспортируемое как export const store = new Store()
Store с подпиской в SSR layout для данных пользователяload возвращает данные → setContext/getContext

Последние две строки — те, которые большинство руководств опускают: writable store, общий для всего приложения, становится экземпляром класса, экспортируемым как const, а пользовательский store в контексте SSR становится данными конкретного запроса, возвращаемыми из load и передаваемыми через context.

Runes делают модель реактивности явной, но ценность — в архитектуре: держите состояние настолько локальным, насколько возможно, переводите его в хранилище-класс уровня модуля только тогда, когда несколько компонентов действительно его разделяют, и ограничивайте его через context, как только в картину входит SSR. Проверьте ваши модули .svelte.ts на наличие $state верхнего уровня, участвующего в серверном рендеринге — эта единственная проверка выявляет наиболее критичный класс ошибок состояния в SvelteKit.

Часто задаваемые вопросы

В чём разница между $state.raw и $state в Svelte 5?

$state возвращает глубокий прокси, отслеживающий мутации на любой глубине, поэтому push() и присваивание свойств вызывают обновления; $state.raw пропускает создание прокси и отслеживает только переназначение — это означает, что мутации на месте игнорируются, и для вызова обновления необходимо заменить значение целиком. Используйте $state.raw для больших, фактически неизменяемых данных, заменяемых как единое целое, — например, распарсенных JSON-ответов, конфигурационных блобов или справочных таблиц, — где важно избежать накладных расходов на проксирование каждого свойства. В остальных случаях используйте $state.

Почему моё значение $state перестаёт обновляться после деструктуризации?

Деструктуризация объекта $state читает значение в момент деструктуризации и создаёт обычную, нереактивную переменную; реактивная привязка к прокси при этом не создаётся. После const name = user.name последующее изменение user.name обновит прокси, но деструктурированный name останется замороженным на исходном значении. Для сохранения реактивности читайте через исходный прокси с помощью user.name в точке использования или передавайте геттер-функцию, например () => user.name, которая замыкается на прокси и перечитывает значение при каждом вызове.

Выполняются ли колбэки $effect во время SSR в SvelteKit?

Нет. Колбэки $effect выполняются только в браузере и никогда — во время серверного рендеринга. Эффекты запускаются после обновления DOM и повторно выполняются при изменении читаемых ими реактивных значений, что означает невозможность их участия в формировании серверно-рендеренного HTML. Не полагайтесь на $effect для генерации вывода во время SSR; поместите эту работу в функцию load или в $derived. Эффекты предназначены исключительно для браузерных побочных эффектов — подписок, логирования, ручной работы с DOM, интервалов и персистентности — с опциональным возвращаемым колбэком очистки.

Являются ли значения, хранящиеся внутри SvelteMap или SvelteSet, глубоко реактивными?

Нет. SvelteMap и SvelteSet из svelte/reactivity отслеживают чтения .size, .get(), .has() и итерацию, а также реагируют на добавление или удаление записей, но хранящиеся внутри них значения не становятся глубоко реактивными. Если вы храните обычный объект в SvelteMap и ожидаете, что мутации его свойств будут вызывать обновления, сначала оберните этот объект в $state, чтобы внутренний объект стал реактивным прокси. Реактивная коллекция отслеживает структуру, но не внутреннее состояние произвольных хранимых значений.

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.