Scope: Работа с изолированными контекстами
Scope - это изолированное окружение для работы с состоянием в effector. Scope позволяет создавать независимые копии состояния всего приложения, что особенно полезно для:
- 🗄️ Server Side Rendering (SSR)
- 🧪 Тестирования компонентов и бизнес-логики
- 🔒 Изоляции состояния для разных пользователей/сессий
- 🚀 Параллельного запуска нескольких инстансов приложения
Scope создает отдельную “вселенную” для юнитов effector, где каждый стор имеет свое независимое состояние, а события и эффекты работают с этим состоянием изолированно от других scope.
Создать scope приложения можно через метод fork. Fork API - это одна из самых мощных особенностей effector.
Правила работы со скоупом
При работе со Scope важно понимать правила вызова эффектов и событий, чтобы избежать потери контекста. Рассмотрим основные паттерны использования:
Правила вызова эффектов
- Эффекты можно безопасно вызывать внутри других эффектов
- Нельзя смешивать вызовы эффектов с обычными асинхронными функциями
const authFx = createEffect(async () => {
// Безопасно - вызов эффекта внутри эффекта
await loginFx();
// Безопасно - Promise.all с эффектами
await Promise.all([loadProfileFx(), loadSettingsFx()]);
});
const authFx = createEffect(async () => {
await loginFx();
// Потеря scope! Нельзя смешивать с обычными промисами
await new Promise((resolve) => setTimeout(resolve, 100));
// Этот вызов будет в глобальном scope
await loadProfileFx();
});
Если не придерживаться этих правил, то это грозит потерей скоупа!
Лучше вызывать эффекты декларативно, при помощи метода sample
!
Работа с начальным состоянием
При создании scope часто требуется задать начальные значения для сторов. Это особенно важно при SSR или тестировании, когда нужно подготовить определенное состояние приложения, сделать это можно при помощи передачи свойства values
в первые аргумент метода fork
.
const scope = fork({
values: [
[$store, "value"],
[$user, { id: 1, name: "Alice" }],
],
});
Свойство values
принимает в себя массив пар со значением [$store, value]
.
Это особенно полезно в случаях:
- Серверного рендеринга (SSR) - чтобы гидрировать клиент нужными данными с сервера
- Тестирования компонентов с разными начальными данными
- Сохранения и восстановления состояния приложения
Использование в SSR
Scope
является ключевым механизмом для реализации SSR в effector.
Представим, что два пользователя зашли к вам на сайт и оба отправили запрос о получение списка пользователей, а так как стор у нас в глобальной области, то здесь бы началось состояние гонки, и чей бы запрос отработал быстрее, те бы данные получили ОБА пользователя, что привело бы к утечки данных между разными пользователями.
При сериализации scope автоматически игнорирует сторы с флагом {serialize: 'ignore'}
. Используйте этот флаг для предотвращения утечки чувствительных данных.
При использовани scope
каждый запрос получает свою копию состояния:
// server.tsx
import { renderToString } from "react-dom/server";
import { fork, serialize } from "effector";
import { Provider } from "effector-react";
import { $users, fetchUsersFx } from "./model";
async function serverRender() {
const scope = fork();
// Загружаем данные на сервере
await allSettled(fetchUsersFx, { scope });
// Рендерим приложение
const html = renderToString(
<Provider value={scope}>
<App />
</Provider>,
);
// Сериализуем состояние для передачи на клиент
const data = serialize(scope);
return `
<html>
<body>
<div id="root">${html}</div>
<script>window.INITIAL_DATA = ${data}</script>
</body>
</html>
`;
}
// client.tsx
import { hydrateRoot } from "react-dom/client";
import { fork } from "effector";
const scope = fork({
values: window.INITIAL_DATA,
});
hydrateRoot(
document.getElementById("root"),
<Provider value={scope}>
<App />
</Provider>,
);
Функция allSettled
принимает в себя событие, эффект или scope, ждет завершения всех порожденных им сайд-эффектов. В данном примере это гарантирует, что все асинхронные операции завершатся до сериализации состояния.
В этом примере мы:
- На сервере создаем scope и запускаем в нем начальную подготовку данных
- Сериализуем состояние scope
- На клиенте восстанавливаем состояние из сериализованных данных
Благодаря использованию Scope мы очень легко можем:
- Подготовить начальное состояние на сервере
- Сериализовать это состояние
- Восстановить состояние на клиенте
- Обеспечить гидратацию без потери реактивности
Метод serialize
преобразует состояние в сериализованное состояние, которая может быть безопасно передано с сервера на клиент. При этом сериализуются только данные, а не функции или методы.
Здесь мы показали вам маленький пример работы с SSR, с более подробным гайдом, как настроить и работать с SSR вы можете прочитать тут.
Тестирование
Scope является мощным инструментом для тестирования, так как позволяет:
- Изолировать тесты друг от друга
- Устанавливать начальное состояние для каждого теста
- Проверять изменения состояния после действий
- Имитировать разные сценарии пользователя
Scope
создает отдельную копию состояния. При этом исходный стор остается неизменным!
Пример тестирования процесса авторизации:
describe("auth flow", () => {
it("should login user", async () => {
// Создаем изолированный scope для теста
const scope = fork();
// Выполняем эффект логина
await allSettled(loginFx, {
scope,
params: {
email: "test@example.com",
password: "123456",
},
});
// Проверяем состояние конкретно в этом scope
expect(scope.getState($user)).toEqual({
id: 1,
email: "test@example.com",
});
});
it("should handle login error", async () => {
const scope = fork();
await allSettled(loginFx, {
scope,
params: {
email: "invalid",
password: "123",
},
});
expect(scope.getState($error)).toBe("Invalid credentials");
expect(scope.getState($user)).toBeNull();
});
});
Моки эффектов
Похожий паттерн для начальных значений может быть использован и для эффектов, чтобы реализовать мок данных, для этого нужно передать handlers
в объект аргумента:
// Можно также передавать моки для эффектов:
const scope = fork({
handlers: [
[effectA, async () => "true"],
[effectB, async () => ({ id: 1, data: "mock" })],
],
});
Потеря и сохранение scope
При обработке асинхронных операций мы можем столкнуться с “потерей” scope. Это происходит потому, что асинхронные операции в JavaScript выполняются в другом цикле событий (event loop), где контекст выполнения уже потерян. В момент создания асинхронной операции scope существует, но к моменту её выполнения он уже недоступен, так как effector не может автоматически сохранить и восстановить контекст через асинхронные границы. Это может происходить при использовании таких API:
setTimeout
/setInterval
addEventListener
webSocket
и др.
Как исправить потерю scope ?
Здесь нам на помощь приходит метод scopeBind
. Он создаёт функцию, привязанную к скоупу в котором метод был вызван, позволяя безопасно вызывать её в дальнейшем.
Рассмотрим пример, где у нас есть два таймера на странице и каждый из них независимо работает, у каждого таймера есть следующие события:
- Остановить таймер -
timerStopped
- Продолжить таймер -
timerStarted
- Сбросить таймер -
timerReset
export const timerStopped = createEvent();
export const timerReset = createEvent();
export const timerStarted = createEvent();
Также у нас будет событие tick
, на которое мы подпишемся стором для обновления счетчика.
Для сохранения результата мы создадим хранилище $timerCount
.
const tick = createEvent();
export const $timerCount = createStore(0)
.on(tick, (seconds) => seconds + 1)
.reset(timerReset);
Не стоит также и забыть об очистке таймера, для этого нам понадобится также создать хранилище $timerId
, чтобы сохранять intervalId
.
А также нам нужны эффекты:
- Для запуска таймера –
startFx
- Для очистки таймера –
stopFx
const TIMEOUT = 1_000;
const timerStopped = createEvent();
const timerReset = createEvent();
const timerStarted = createEvent();
const tick = createEvent();
// запуск таймера
const startFx = createEffect(() => {
const intervalId = setInterval(() => {
// здесь вся проблема
tick();
}, TIMEOUT);
return intervalId;
});
// остановка таймера
const stopFx = createEffect((timerId: number) => {
clearInterval(timerId);
});
// id таймера для очистки
const $timerId = createStore<null | number>(null)
.on(startFx.doneData, (_, timerId) => timerId)
.on(stopFx.finally, () => null);
const $timerCount = createStore(0)
.on(tick, (seconds) => seconds + 1)
.reset(timerReset);
// логика запуска таймера
sample({
clock: timerStarted,
filter: $timerId.map((timerId) => !timerId),
target: startFx,
});
// логика остановки таймера
sample({
clock: timerStopped,
source: $timerId,
filter: Boolean,
target: stopFx,
});
Обратите внимание на вызов tick
в setInterval
, мы вызываем его напрямую. Здесь и кроется вся проблема, как мы писали выше, к моменту вызова tick
скоуп уже мог измениться, либо удалиться - проще говоря “потеряться”. Однако благодаря scopeBind
мы связываем событие tick
c нужным нам скоупом.
const startFx = createEffect(() => {
const intervalId = setInterval(() => {
tick();
}, TIMEOUT);
return intervalId;
});
const startFx = createEffect(() => {
const bindedTick = scopeBind(tick);
const intervalId = setInterval(() => {
bindedTick();
}, TIMEOUT);
return intervalId;
});
Возможно вы уже заметили, что мы не передаем в scopeBind
сам scope
, это связано с тем, что текущий скоуп находится в глобальной переменной, и функция scopeBind
замыкает нужный скоуп в себе в момент вызова. Однако, если вам нужно, то вы можете передать нужный scope
в объекта второго аргумента.
И того мы имеем:
import { createEffect, createEvent, createStore, sample, scopeBind } from "effector";
const TIMEOUT = 1_000;
const timerStopped = createEvent();
const timerReset = createEvent();
const timerStarted = createEvent();
const tick = createEvent();
// запуск таймера
const startFx = createEffect(() => {
// привязываем событие к текущему активному скоупу
const bindedTick = scopeBind(tick);
const intervalId = setInterval(() => {
bindedTick();
}, TIMEOUT);
return intervalId;
});
// остановка и очистка таймера
const stopFx = createEffect((timerId: number) => {
clearInterval(timerId);
});
// счетчик времени в секундах
const $timerCount = createStore(0)
.on(tick, (seconds) => seconds + 1)
.reset(timerReset);
// id таймера
const $timerId = createStore<null | number>(null)
.on(startFx.doneData, (_, timerId) => timerId)
.reset(stopFx.finally);
// логика запуска таймера
sample({
clock: timerStarted,
filter: $timerId.map((timerId) => !timerId),
target: startFx,
});
// логика остановки таймера
sample({
clock: timerStopped,
source: $timerId,
filter: Boolean,
target: stopFx,
});
Если вы используете effector в связке с фреймворками (React, Vue и др.), то вы можете просто использовать хук useUnit
для юнитов (store, event и effect), он сам свяжет их с текущим активным скоупом.
Почему происходит потеря scope?
Давайте представим, то как работает скоуп в effector:
// наш активный скоуп
let scope;
function process() {
try {
scope = "effector";
asyncProcess();
} finally {
scope = undefined;
console.log("наш скоуп undefined");
}
}
async function asyncProcess() {
console.log("у нас есть скоуп", scope); // effector
await 1;
// тут мы уже потеряли контекст
console.log("а здесь скоупа уже нет ", scope); // undefined
}
process();
// Вывод:
// у нас есть скоуп effector
// наш скоуп undefined
// а здесь скоупа уже нет undefined
Потеря scope может привести к тому, что:
- Обновления данных не попадут в нужный скоуп
- Клиент получит неконсистентное состояние
- Изменения не будут отражены в UI
- Возможны утечки данных между разными пользователями при SSR
Возможно вас интересует вопрос “Это проблема именно эффектора?”, однако это общий принцип работы с асинхронностью в JavaScript, все технологии, которые сталкиваются с необходимостью сохранения контекста в котором происходят вызовы так или иначе обходят это затруднение. Самый характерный пример это zone.js,
который для сохранения контекста оборачивает все асинхронные глобальные функции вроде setTimeout
или Promise.resolve
. Также способами решения этой проблемы бывает использование генераторов или ctx.schedule(() => asyncCall())
.
В JavaScript готовится proposal Async Context, который призван решить проблему потери контекста на уровне языка. Это позволит:
- Сохранять контекст автоматически через все асинхронные вызовы
- Избавиться от необходимости явного использования scopeBind
- Получить более предсказуемое поведение асинхронного кода
Как только это предложение войдет в язык и получит широкую поддержку, effector будет обновлен для использования этого нативного решения.
Документация на английском языке - самая актуальная, поскольку её пишет и обновляет команда effector. Перевод документации на другие языки осуществляется сообществом по мере наличия сил и желания.
Помните, что переведенные статьи могут быть неактуальными, поэтому для получения наиболее точной и актуальной информации рекомендуем использовать оригинальную англоязычную версию документации.