Skip to content

Скоупы

Scope — это один запущенный экземпляр состояния.

Модель описывает сторы, события, эффекты и реакции. Scope хранит фактические значения сторов. Благодаря этому одна модель может одновременно работать в приложении, тесте, SSR-запросе, виджете или фоне кешированного экрана.

Scope для чтения стора

Когда код читает count.value, стор должен понять, какую копию count вы имеете в виду. Поэтому прямое чтение и запись требуют scope в текущем контексте выполнения.

ts
scoped(appScope, () => {
  count.value += 1;
});

scoped(scope, fn) открывает scope, выполняет функцию и восстанавливает предыдущий scope после завершения. Если функция возвращает promise, тот же scope сохраняется для этой promise-цепочки до ее завершения.

Когда scope отсутствует

Чтение или запись стора, а также вызов события или эффекта, когда в текущем контексте нет scope, бросает ошибку Scope is required. Сообщение называет юнит, которому потребовался scope, и перечисляет способы его предоставить:

text
Scope is required to call event "submitted", but no scope is active.

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

text
Unit path that led here: reaction "applyCheckout" → effect "chargeFx" → event "submitted".

По этому пути легко найти, где scope был потерян. Обычная причина — сырой await (голый fetch или таймер) между двумя юнитами внутри тела реакции. Оберните эту работу в effect, который сохраняет scope через свой await.

Один инструмент для запуска и callback-функций

scoped можно использовать сразу:

ts
await scoped(appScope, async () => {
  const response = await fetch("/api/count");
  count.value = (await response.json()).count;
});

А можно создать runner и переиспользовать его там, где иначе код начал бы расползаться:

ts
const inAppScope = scoped(appScope);

await inAppScope(async () => {
  count.value += 1;
});

const onMessage = inAppScope.wrap((message: string) => {
  messages.items = [...messages.items, message];
});

socket.on("message", onMessage);

Так один и тот же объект решает две задачи: выполнить работу прямо сейчас или сохранить callback-функцию, которая позже вернется в тот же scope.

Если код уже выполняется внутри scope, аргумент можно не передавать — scoped() захватывает scope, в котором сейчас выполняется. Это идиоматичный способ привязать колбэк к текущему scope (таймер, слушатель, сокет), не называя scope руками:

ts
scoped(appScope, () => {
  // `scoped()` подхватывает активный scope.
  scoped(() => {
    count.value += 1;
  });

  // Захватываем текущий scope для последующего колбэка.
  const onMessage = scoped().wrap((message: string) => {
    messages.items = [...messages.items, message];
  });
  socket.on("message", onMessage);
});

Когда у колбэка не оказывается scope — потому что его отдали таймеру, слушателю, сокету или сырому await, — это потеря scope. Обёртка через scoped() — это решение.

Заполнение значений scope

Свежий scope стартует каждый стор с его начального значения. Чтобы запустить scope с конкретными значениями — это типично для тестов, SSR-гидрации или предпросмотров — передайте их при создании scope:

ts
const appScope = scope({
  values: [
    [count, 10],
    [query, "docs"],
  ],
});

Чтобы выставить значение в уже существующем scope императивно, используйте seedScopeStoreValue. Заполнять можно только writable-сторы; derived- и read-only-сторы бросят ошибку.

ts
seedScopeStoreValue(appScope, count, 10);

Чтение текущего scope

getCurrentScope() возвращает scope, активный в текущем контексте выполнения, или null, если ни один scope не открыт. Тянитесь к нему, только когда действительно нужен сам объект scope. Чтобы захватить текущий scope и переоткрыть его в последующем callback, предпочитайте scoped() — он сам захватывает активный scope, так что scope не нужно передавать руками.

ts
const current = getCurrentScope();

Запуск юнитов на границах

scoped удобен для обычного кода. Он же подходит и когда вы запускаете юнит на границе системы — из теста, серверного загрузчика, команды или адаптера фреймворка: оберните вызов в scoped и await-ните возвращённый промис.

ts
await scoped(appScope, () => incremented(1));

scoped явно показывает, какой scope владеет состоянием, а callback — какой юнит стартует и какой payload входит в граф. Его промис также ждет завершения асинхронной работы, поднятой этим запуском.

Правила scope

Scope живёт в текущем контексте выполнения, поэтому главное — не потерять его через await. Правила:

  1. Чтение и запись требуют scope. store.value, event(), effect() и dependency.value разрешаются относительно активного scope. Если ни один не активен — бросается Scope is required.
  2. Scope активен внутри блока scoped(scope, …), обработчика эффекта и тела реакции — и любого синхронного кода, который они вызывают. Юнит выполняется в том scope, в котором сработал его источник.
  3. Ожидание юнита сохраняет scope. После await someFx() или await someEvent() продолжение выполняется в том же scope, поэтому сразу после можно читать сторы. Эффекты и события восстанавливают scope, который был активен в момент их вызова.
  4. Сырой await теряет scope. await fetch(), await delay(), await anyPromise() — у продолжения нет scope, и следующее чтение стора бросит ошибку. Оборачивайте внешнюю асинхронность в effect, чтобы scope пережил границу.
  5. На границах системы называйте scope явно. Из теста, загрузчика, команды или callback-функции, отданной другой библиотеке, используйте scoped(scope, fn) или scoped(scope).wrap(cb), а не рассчитывайте на ambient scope.

Правило большого пальца: внутри реакции или эффекта await-ьте только юнитыawait someFx(), await someEvent(), await scoped(...). Как только вы сделали await голого промиса — вы вышли из scope; перенесите эту работу в effect.

О конкретных способах потерять scope — таймеры, слушатели, сокеты, сырые await — и как чинить каждый, см. Потеря scope.