Перейти к содержимому

68. Effector: Продвинутый уровень

Effector: Продвинутый уровень — оркестрация и масштабирование

Заголовок раздела «Effector: Продвинутый уровень — оркестрация и масштабирование»

Ты освоил базу Effector. Теперь погружаемся в продвинутые паттерны — это те инструменты, которые превращают хорошее приложение в отличное: быстрое, предсказуемое и лёгкое для тестирования 🏆

Представь sample как умного диспетчера аэропорта ✈️: он знает, когда самолёт готов к вылету (clock), какой груз взять (source), как его упаковать (fn) и куда отправить (target).

sample — это сердце реактивной логики. Он забирает данные из source в момент срабатывания clock и передаёт в target:

import { sample } from 'effector';
// Полная форма:
sample({
clock: trigger, // когда срабатывать (Event | Store | Effect | массив)
source: $data, // откуда брать данные (Store | Event | объект сторов)
filter: $condition, // условие (Store<boolean> | функция-предикат)
fn: (data, clockPayload) => transform(data), // трансформация
target: output, // куда отправить (Event | Effect | Store | массив)
});
// Минимальная форма (clock === source):
sample({ clock: buttonClicked, target: submitFx });
// Забрать значение стора при событии:
sample({
clock: submitClicked,
source: $formData,
target: submitFormFx,
});
// С трансформацией:
sample({
clock: userTyped,
source: { query: $query, filters: $filters },
fn: ({ query, filters }) => ({ q: query.trim(), ...filters }),
target: searchFx,
});
// Несколько источников объединяются в объект
sample({
clock: pageOpened,
source: { userId: $userId, token: $token },
fn: ({ userId, token }) => ({ userId, headers: { Authorization: token } }),
target: fetchUserFx,
});
// Несколько целей — отправить в несколько мест одновременно
sample({
clock: loginSuccess,
source: $userData,
target: [$user, persistToStorageFx, analyticsTrackFx], // рассылаем всем
});
// filter как Store<boolean>
sample({
clock: addToCartClicked,
source: $product,
filter: $isLoggedIn, // выполнится только если пользователь залогинен
target: addToCartFx,
});
// filter как предикат-функция
sample({
clock: priceChanged,
source: $price,
filter: (price) => price > 0, // выполнится только при положительной цене
target: updatePriceFx,
});
// Типизированный guard через filter
sample({
clock: itemSelected,
source: $selectedItem,
filter: (item): item is NonNullable<typeof item> => item !== null,
target: editItemFx,
});

attach() — фабрика эффектов с привязкой к данным

Заголовок раздела «attach() — фабрика эффектов с привязкой к данным»

attach создаёт новый эффект, который автоматически получает данные из стора:

import { attach } from 'effector';
const $token = createStore<string>('');
// Базовый эффект (без авторизации)
const baseRequestFx = createEffect(async ({ url, token }: { url: string; token: string }) => {
const res = await fetch(url, { headers: { Authorization: \`Bearer \${token}\` } });
return res.json();
});
// attach: привязываем $token к baseRequestFx
const fetchProfileFx = attach({
source: $token,
effect: baseRequestFx,
mapParams: (url: string, token) => ({ url, token }), // склеиваем параметры
});
// Теперь fetchProfileFx принимает только url — token берётся автоматически!
fetchProfileFx('/api/profile');
import { split } from 'effector';
const statusChanged = createEvent<'success' | 'error' | 'pending'>();
// split разделяет один поток на несколько по условиям
const { successStatus, errorStatus, pendingStatus } = split(statusChanged, {
successStatus: (s) => s === 'success',
errorStatus: (s) => s === 'error',
pendingStatus: (s) => s === 'pending',
});
// Каждый поток — самостоятельное событие
$successMessages.on(successStatus, (msgs) => [...msgs, 'Операция выполнена!']);
$errorMessages.on(errorStatus, (msgs) => [...msgs, 'Произошла ошибка!']);

Domain позволяет группировать юниты и управлять ими централизованно (например, для DevTools или тестов):

import { createDomain } from 'effector';
const authDomain = createDomain('auth');
// Создаём юниты через домен
const $user = authDomain.createStore<User | null>(null);
const login = authDomain.createEvent<Credentials>();
const logout = authDomain.createEvent();
const loginFx = authDomain.createEffect(async (creds: Credentials) => {
return api.login(creds);
});
// Домен позволяет отслеживать все юниты внутри
authDomain.onCreateStore((store) => {
console.log('Новый стор в домене:', store.shortName);
});

Fork API — главное преимущество Effector для SSR и изолированного тестирования:

import { fork, allSettled, serialize, hydrate } from 'effector';
// --- SSR (сервер) ---
async function renderPage(userId: number) {
// Создаём изолированный scope — не трогаем глобальное состояние!
const scope = fork();
// Выполняем эффекты в изолированном scope
await allSettled(fetchUserFx, { scope, params: userId });
// Сериализуем для передачи клиенту
const data = serialize(scope); // => { "$user": {...}, "$settings": {...} }
return { data };
}
// --- SSR (клиент) ---
const scope = fork({ values: window.__EFFECTOR_DATA__ });
// --- Тестирование ---
describe('counter model', () => {
it('should increment', async () => {
const scope = fork({
values: [[$count, 5]], // начальное значение стора
});
await allSettled(incremented, { scope });
expect(scope.getState($count)).toBe(6); // проверяем стор в scope
});
it('should load user', async () => {
const scope = fork({
handlers: [ // мокируем эффекты
[fetchUserFx, async () => ({ id: 1, name: 'Test User' })],
],
});
await allSettled(pageOpened, { scope });
expect(scope.getState($user)).toEqual({ id: 1, name: 'Test User' });
});
});
import { scopeBind } from 'effector';
// Вне React (например, в WebSocket обработчике)
const scope = fork();
// Привязываем событие к конкретному scope
const boundIncrement = scopeBind(incremented, { scope });
// Теперь boundIncrement() работает в изоляции scope
socket.on('tick', () => boundIncrement());
import { debounce, throttle, every, some, condition, reset } from 'patronum';
// Дебаунс события
const debouncedSearch = debounce({ source: searchTyped, timeout: 300 });
// Составной filter: оба условия должны быть true
const $canSubmit = every({ stores: [$nameValid, $emailValid, $ageValid], predicate: Boolean });
// Условие: if/else для юнитов
condition({
source: submitClicked,
if: $isLoggedIn,
then: submitFormFx,
else: redirectToLoginFx,
});
// Сброс нескольких сторов одним событием
reset({ clock: logoutClicked, target: [$user, $token, $cart, $settings] });
features/auth/model.ts
import { createStore, createEvent, createEffect, sample } from 'effector';
// Events
export const loginFormSubmitted = createEvent<LoginForm>();
export const logoutClicked = createEvent();
// Effects
export const loginFx = createEffect(api.login);
export const logoutFx = createEffect(api.logout);
// Stores
export const $user = createStore<User | null>(null);
export const $authToken = createStore<string | null>(null);
export const $isAuth = $user.map(Boolean);
// Logic
$user
.on(loginFx.doneData, (_, { user }) => user)
.reset(logoutFx.done);
$authToken
.on(loginFx.doneData, (_, { token }) => token)
.reset(logoutFx.done);
sample({ clock: loginFormSubmitted, target: loginFx });
sample({ clock: logoutClicked, target: logoutFx });
// features/profile/model.ts — зависит от auth
import { $authToken } from '../auth/model';
const fetchProfileFx = attach({
source: $authToken,
effect: async (token, userId: number) => {
return api.getProfile(userId, token!);
},
});

Попробуйте примеры в интерактивном редакторе: