Tarjima qoshish uchun havola boyicha o'tib Pull Request oching (havolaga o'tish).
Standart til uchun tarkibni ko'rsatadi.
Scope: Working with Isolated Contexts
Scope is an isolated environment for state management in Effector. Scope allows creating independent copies of the entire application state, which is particularly useful for:
- 🗄️ Server Side Rendering (SSR)
- 🧪 Testing components and business logic
- 🔒 Isolating state for different users/sessions
- 🚀 Running multiple application instances in parallel
Scope creates a separate “universe” for Effector units, where each store has its independent state, and events and effects work with this state in isolation from other scopes.
You can create an application scope using the fork method. Fork API is one of the most powerful features of Effector.
And voila, now the Counter
component works with its isolated state - this is exactly what we wanted. Let’s look at other important applications of Scope
.
You don’t need to manually track that each operation is performed in the correct scope. Effector does this automatically; just call the event chain with a specific scope using the return value of useUnit
in component or allSettled
.
Rules for Working with Scope
When working with Scope, it’s important to understand the rules for calling effects and events to avoid context loss. Let’s look at the main usage patterns:
Rules for Effect Calls
- Effects can be safely called inside other effects
- You can’t mix effect calls with regular async functions
const authFx = createEffect(async () => {
// Safe - calling an effect inside an effect
await loginFx();
// Safe - Promise.all with effects
await Promise.all([loadProfileFx(), loadSettingsFx()]);
});
const authFx = createEffect(async () => {
await loginFx();
// Scope loss! Can't mix with regular promises
await new Promise((resolve) => setTimeout(resolve, 100));
// This call will be in the global scope
await loadProfileFx();
});
If you don’t follow these rules, it could lead to scope loss!
It’s better to call effects declaratively using the sample
method!
Working with Initial State
When creating a scope, it’s often necessary to set initial values for stores. This is especially important for SSR or testing, when you need to prepare a specific application state. You can do this by passing the values
property in the first argument of the fork
method.
const scope = fork({
values: [
[$store, "value"],
[$user, { id: 1, name: "Alice" }],
],
});
The values
property accepts an array of pairs with the value [$store, value]
.
This is especially useful in cases of:
- Server Side Rendering (SSR) - to hydrate the client with necessary data from the server
- Testing components with different initial data
- Saving and restoring application state
Scope
creates a separate copy of the state. The original store remains unchanged!
SSR Usage
Scope is a key mechanism for implementing SSR in Effector. Imagine two users visiting your website and both sending requests to get a list of users. Since the store is in the global scope, a race condition would occur here, and whichever request completes faster, BOTH users would receive that data, leading to data leaks between different users.
When serializing scope, stores with the flag {serialize: 'ignore'}
are automatically ignored. Use this flag to prevent sensitive data leaks.
When using scope, each request gets its own copy of the state:
// 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();
// Load data on the server
await allSettled(fetchUsersFx, { scope });
// Render the application
const html = renderToString(
<Provider value={scope}>
<App />
</Provider>,
);
// Serialize state for transfer to the client
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>,
);
The allSettled
function accepts an event
, effect
, or scope
, and waits for all side effects it spawns to complete. In this example, this ensures that all asynchronous operations complete before state serialization.
In this example we:
- Create a scope on the server and run initial data preparation in it
- Serialize the scope state
- Restore state from serialized data on the client
Thanks to using Scope, we can easily:
- Prepare initial state on the server
- Serialize this state
- Restore state on the client
- Ensure hydration without losing reactivity
The serialize
method transforms state into serialized form that can be safely transferred from server to client. Only data is serialized, not functions or methods.
Here we’ve shown you a small example of working with SSR. For a more detailed guide on how to set up and work with SSR, you can read here.
Testing
Scope is a powerful tool for testing as it allows:
- Isolating tests from each other
- Setting initial state for each test
- Checking state changes after actions
- Simulating different user scenarios
Example of testing the authorization process:
describe("auth flow", () => {
it("should login user", async () => {
// Create isolated scope for test
const scope = fork();
// Execute login effect
await allSettled(loginFx, {
scope,
params: {
email: "test@example.com",
password: "123456",
},
});
// Check state specifically in this 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();
});
});
Mocking effects
A similar pattern for initial values can be used for effects to implement mock data. For this, you need to pass handlers
in the argument object:
// You can also pass mocks for effects:
const scope = fork({
handlers: [
[effectA, async () => "true"],
[effectB, async () => ({ id: 1, data: "mock" })],
],
});
Scope Loss and Binding
When handling asynchronous operations, we might encounter scope “loss”. This happens because asynchronous operations in JavaScript execute in a different event loop cycle, where the execution context is already lost. At the moment of creating an asynchronous operation, the scope exists, but by the time it executes, it’s no longer accessible, as Effector cannot automatically preserve and restore context across asynchronous boundaries. This can happen when using APIs such as:
setTimeout
/setInterval
addEventListener
webSocket
и др.
How to Fix Scope Loss?
This is where the scopeBind
method comes to help. It creates a function bound to the scope in which the method was called, allowing it to be safely called later.
Let’s look at an example where we have two timers on a page and each works independently. Each timer has the following events:
- Stop timer -
timerStopped
- Start timer -
timerStarted
- Reset timer -
timerReset
export const timerStopped = createEvent();
export const timerReset = createEvent();
export const timerStarted = createEvent();
We’ll also have a tick
event that our store will subscribe to for updating the counter.
To store the result, we’ll create a $timerCount
store.
const tick = createEvent();
export const $timerCount = createStore(0)
.on(tick, (seconds) => seconds + 1)
.reset(timerReset);
Don’t forget about clearing the timer; for this, we’ll also need to create a $timerId
store to save the intervalId
.
We also need effects for:
- Starting the timer –
startFx
- Clearing the timer –
stopFx
const TIMEOUT = 1_000;
const timerStopped = createEvent();
const timerReset = createEvent();
const timerStarted = createEvent();
const tick = createEvent();
// start timer
const startFx = createEffect(() => {
const intervalId = setInterval(() => {
// here's the whole problem
tick();
}, TIMEOUT);
return intervalId;
});
// stop and clear timer
const stopFx = createEffect((timerId: number) => {
clearInterval(timerId);
});
// timer id
export const $timerId = createStore<null | number>(null)
.on(startFx.doneData, (_, timerId) => timerId)
.on(stopFx.finally, () => null);
// start timer logic
sample({
clock: timerStarted,
filter: $timerId.map((timerId) => !timerId),
target: startFx,
});
// stop timer logic
sample({
clock: timerStopped,
source: $timerId,
filter: Boolean,
target: stopFx,
});
Notice the tick call in setInterval
- we’re calling it directly. This is where the whole problem lies, as we mentioned above, by the time tick
is called, the scope might have changed or been removed - in other words, “lost”. However, thanks to scopeBind
, we bind the tick
event to the scope we need.
const startFx = createEffect(() => {
const intervalId = setInterval(() => {
tick();
}, TIMEOUT);
return intervalId;
});
const startFx = createEffect(() => {
const bindedTick = scopeBind(tick);
const intervalId = setInterval(() => {
bindedTick();
}, TIMEOUT);
return intervalId;
});
You may have already noticed that we don’t pass the scope itself to scopeBind
; this is because the current scope is in a global variable, and the scopeBind
function captures the needed scope in itself at the moment of calling. However, if you need to, you can pass the needed scope
in the second argument object.
So altogether we have:
import { createEffect, createEvent, createStore, sample, scopeBind } from "effector";
const TIMEOUT = 1_000;
const timerStopped = createEvent();
const timerReset = createEvent();
const timerStarted = createEvent();
const tick = createEvent();
// start timer
const startFx = createEffect(() => {
// bind event to scope, so our data doesn't get lost
const bindedTick = scopeBind(tick);
const intervalId = setInterval(() => {
bindedTick();
}, TIMEOUT);
return intervalId;
});
// stop and clean timer
const stopFx = createEffect((timerId: number) => {
clearInterval(timerId);
});
// timer count in seconds
const $timerCount = createStore(0)
.on(tick, (seconds) => seconds + 1)
.reset(timerReset);
// timer id
const $timerId = createStore<null | number>(null)
.on(startFx.doneData, (_, timerId) => timerId)
.reset(stopFx.finally);
// start timer logic
sample({
clock: timerStarted,
filter: $timerId.map((timerId) => !timerId),
target: startFx,
});
// stop timer logic
sample({
clock: timerStopped,
source: $timerId,
filter: Boolean,
target: stopFx,
});
If you are using effector with integrations like 📘 React, 📗 Vue etc. you can use hook useUnit
for units (store, event and effect). This hook automatically binds the unit to the current scope.
Why Does Scope Loss Occur?
Let’s imagine how scope work under the hood:
// out current scope
let scope;
function process() {
try {
scope = "effector";
asyncProcess();
} finally {
scope = undefined;
console.log("scope is undefined");
}
}
async function asyncProcess() {
console.log("here is ok", scope); // effector
await 1;
// here we already lost context
console.log("but here is not ok ", scope); // undefined
}
process();
// Output:
// here is ok effector
// scope is undefined
// but here is not ok undefined
Scope loss can lead to:
- Updates not reaching the correct scope
- Client receiving inconsistent state
- Changes not being reflected in the UI
- Possible data leaks between different users during SSR
You might be wondering “Is this specifically an Effector problem?”, but this is a general principle of working with asynchronicity in JavaScript. All technologies that face the need to preserve the context in which calls occur somehow work around this difficulty. The most characteristic example is zone.js,
which wraps all asynchronous global functions like setTimeout
or Promise.resolve
to preserve context. Other solutions to this problem include using generators or ctx.schedule(() => asyncCall())
.
JavaScript is preparing a proposal Async Context, which aims to solve the context loss problem at the language level. This will allow:
Automatically preserving context through all asynchronous calls Eliminating the need for explicit use of scopeBind Getting more predictable behavior of asynchronous code
Once this proposal enters the language and receives wide support, Effector will be updated to use this native solution.
Ingliz tilidagi hujjatlar eng dolzarb hisoblanadi, chunki u effector guruhi tomonidan yozilgan va yangilanadi. Hujjatlarni boshqa tillarga tarjima qilish jamiyat tomonidan kuch va istaklar mavjud bo'lganda amalga oshiriladi.
Esda tutingki, tarjima qilingan maqolalar yangilanmasligi mumkin, shuning uchun eng aniq va dolzarb ma'lumot uchun hujjatlarning asl inglizcha versiyasidan foydalanishni tavsiya etamiz.