Введение
Effector - это стейт-менеджер, построенный на принципах реактивного программирования и событийной архитектуры. Состояние описывается через три базовых юнита - Store, Event, Effect - и их связи. Связи между юнитами задаются декларативно, что делает граф зависимостей прозрачным и предсказуемым.
Ключевые принципы:
- Событийная модель - любое изменение начинается с события
- Декларативные связи - юниты соединяются через операторы, а не императивный код
- Независимость от фреймворка - ядро Effector работает без React, Vue или других библиотек
- Типобезопасность - типы выводятся автоматически по всему графу зависимостей
- Поддержка SSR из коробки через механизм Scope
Effector vs Redux
В Redux действие проходит через редьюсеры, где каждый решает, реагировать или нет. В Effector связь между событием и стором задаётся явно через
.on()илиsample(). Это упрощает отслеживание потока данных, потому что все зависимости видны в одном месте.
Установка
npm install effector effector-reactДля работы с плагином Babel/SWC, который добавляет имена юнитам автоматически:
npm install -D effector/babel-plugin
# или для SWC
npm install -D @effector/swc-plugin// .babelrc
{
"plugins": ["effector/babel-plugin"]
}Базовые юниты
Store
Store - контейнер реактивного состояния. Хранит текущее значение и обновляется при срабатывании связанных событий.
import { createStore, createEvent } from 'effector';
// создание стора с начальным значением
const $counter = createStore(0);
// создание событий
const increment = createEvent();
const decrement = createEvent();
const reset = createEvent();
// привязка событий к стору
$counter
.on(increment, (state) => state + 1)
.on(decrement, (state) => state - 1)
.reset(reset);
// подписка на изменения (вне React)
$counter.watch((value) => console.log('counter:', value));Конвенция именования
В Effector принято называть сторы с префиксом
$- это позволяет визуально отличать сторы от событий и эффектов в коде.
Event
Event - юнит, который описывает намерение что-то сделать. События запускают обновления сторов и могут нести полезную нагрузку.
import { createEvent } from 'effector';
// событие без данных
const buttonClicked = createEvent();
// событие с типизированными данными
const nameChanged = createEvent<string>();
const userSelected = createEvent<{ id: string; name: string }>();
// вызов события
buttonClicked();
nameChanged('Alice');
userSelected({ id: '1', name: 'Alice' });
// prepend - создание нового события с маппингом данных
const inputChanged = nameChanged.prepend(
(e: React.ChangeEvent<HTMLInputElement>) => e.target.value
);Effect
Effect - юнит для описания асинхронных операций с побочными эффектами. Автоматически генерирует события done, fail и pending.
import { createEffect } from 'effector';
interface LoginParams {
email: string;
password: string;
}
interface AuthResponse {
user: User;
token: string;
}
const loginFx = createEffect<LoginParams, AuthResponse, Error>(
async (params) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error('Login failed');
}
return response.json();
}
);
// автоматически генерируемые события
loginFx.done; // { params, result }
loginFx.fail; // { params, error }
loginFx.finally; // { params, status, result?, error? }
loginFx.pending; // Store<boolean>// использование эффекта со сторами
const $user = createStore<User | null>(null);
const $error = createStore<string | null>(null);
const $isLoading = loginFx.pending;
$user.on(loginFx.doneData, (_, user) => user);
$error
.on(loginFx.failData, (_, error) => error.message)
.reset(loginFx);Производные сторы
combine
Объединяет несколько сторов в один.
import { combine, createStore } from 'effector';
const $firstName = createStore('John');
const $lastName = createStore('Doe');
// объект
const $fullName = combine({ first: $firstName, last: $lastName });
// { first: 'John', last: 'Doe' }
// с трансформацией
const $displayName = combine(
$firstName,
$lastName,
(first, last) => `${first} ${last}`
);
// 'John Doe'map
Создаёт производный стор через трансформацию.
const $users = createStore<User[]>([]);
const $userCount = $users.map((users) => users.length);
const $activeUsers = $users.map((users) =>
users.filter((u) => u.isActive)
);
const $userNames = $users.map((users) =>
users.map((u) => u.name)
);Операторы
sample
Центральный оператор Effector. Связывает юниты, позволяя взять данные из источника при срабатывании триггера и передать их в цель.
import { sample, createStore, createEvent, createEffect } from 'effector';
const $form = createStore({ email: '', password: '' });
const submitClicked = createEvent();
const loginFx = createEffect<LoginParams, AuthResponse, Error>(/* ... */);
// при submitClicked - взять данные из $form и передать в loginFx
sample({
clock: submitClicked,
source: $form,
target: loginFx,
});
// с фильтрацией
const $isValid = createStore(false);
sample({
clock: submitClicked,
source: $form,
filter: $isValid,
target: loginFx,
});
// с маппингом данных
sample({
clock: submitClicked,
source: $form,
fn: (form) => ({
email: form.email.trim().toLowerCase(),
password: form.password,
}),
target: loginFx,
});guard (устаревший, используйте sample с filter)
В ранних версиях Effector guard использовался для условной маршрутизации событий. Сейчас рекомендуется sample с параметром filter.
// устаревший вариант
guard({
source: formSubmitted,
filter: $isValid,
target: submitFx,
});
// рекомендуемый вариант
sample({
clock: formSubmitted,
filter: $isValid,
target: submitFx,
});split
Разделяет событие на несколько веток по условиям.
import { split, createEvent } from 'effector';
interface Notification {
type: 'success' | 'error' | 'warning';
message: string;
}
const notificationReceived = createEvent<Notification>();
const showSuccess = createEvent<Notification>();
const showError = createEvent<Notification>();
const showWarning = createEvent<Notification>();
split({
source: notificationReceived,
match: (notification) => notification.type,
cases: {
success: showSuccess,
error: showError,
warning: showWarning,
},
});// split с условиями
const $age = createStore(0);
const ageChanged = createEvent<number>();
const { child, teen, adult } = split(ageChanged, {
child: (age) => age < 13,
teen: (age) => age >= 13 && age < 18,
adult: (age) => age >= 18,
});Effector + React
useUnit
Рекомендуемый хук для подключения любых юнитов к React-компонентам. Заменяет устаревшие useStore и useEvent.
import { useUnit } from 'effector-react';
function Counter() {
const { count, onIncrement, onDecrement, onReset } = useUnit({
count: $counter,
onIncrement: increment,
onDecrement: decrement,
onReset: reset,
});
return (
<div>
<span>{count}</span>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
<button onClick={onReset}>Reset</button>
</div>
);
}// useUnit с эффектами - автоматически подписывается на pending
function LoginForm() {
const { form, isLoading, onSubmit, onEmailChange, onPasswordChange } = useUnit({
form: $form,
isLoading: loginFx.pending,
onSubmit: submitClicked,
onEmailChange: emailChanged,
onPasswordChange: passwordChanged,
});
return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit(); }}>
<input
value={form.email}
onChange={(e) => onEmailChange(e.target.value)}
disabled={isLoading}
/>
<input
type="password"
value={form.password}
onChange={(e) => onPasswordChange(e.target.value)}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Вход...' : 'Войти'}
</button>
</form>
);
}useGate
Gate - механизм для привязки жизненного цикла компонента к событиям.
import { createGate } from 'effector-react';
import { sample } from 'effector';
const PageGate = createGate<{ id: string }>();
// при открытии страницы - загрузить данные
sample({
clock: PageGate.open,
fn: ({ id }) => id,
target: fetchPostFx,
});
// при закрытии - очистить состояние
sample({
clock: PageGate.close,
target: resetPost,
});
function PostPage({ id }: { id: string }) {
useGate(PageGate, { id });
const { post, isLoading } = useUnit({
post: $post,
isLoading: fetchPostFx.pending,
});
if (isLoading) return <div>Загрузка...</div>;
if (!post) return <div>Пост не найден</div>;
return <article>{post.title}</article>;
}Domain API
Domain группирует юниты и позволяет управлять ими как единым целым. Полезен для логирования, тестирования и SSR.
import { createDomain } from 'effector';
const authDomain = createDomain('auth');
// создание юнитов в домене
const $user = authDomain.createStore<User | null>(null);
const $token = authDomain.createStore<string | null>(null);
const login = authDomain.createEvent<Credentials>();
const logout = authDomain.createEvent();
const loginFx = authDomain.createEffect<Credentials, AuthResponse, Error>();
// перехват всех событий домена
authDomain.onCreateEvent((event) => {
event.watch((payload) => {
console.log(`[auth] event: ${event.shortName}`, payload);
});
});
// перехват всех сторов домена
authDomain.onCreateStore((store) => {
store.watch((state) => {
console.log(`[auth] store: ${store.shortName}`, state);
});
});DevTools
Effector поддерживает инспекцию через пакет effector-logger или встроенный Inspector.
npm install -D effector-loggerimport { attachLogger } from 'effector-logger';
// подключение логирования всех юнитов
attachLogger();
// или выборочно
attachLogger($counter);
attachLogger(loginFx);Для визуальной отладки существует расширение @effector/inspector:
npm install -D @effector/inspectorimport { attachInspector } from '@effector/inspector';
if (process.env.NODE_ENV === 'development') {
attachInspector();
}SSR с Scope
Effector реализует SSR через механизм Scope - изолированный контейнер состояния для каждого запроса.
import { fork, allSettled } from 'effector';
// серверная обработка запроса
async function handleRequest(req: Request) {
// создаём изолированный scope для запроса
const scope = fork();
// выполняем начальную загрузку данных
await allSettled(appStarted, { scope, params: req.url });
// получаем значения сторов из scope
const user = scope.getState($user);
const posts = scope.getState($posts);
// сериализация scope для передачи на клиент
const serialized = serialize(scope);
return renderToString(
<Provider value={scope}>
<App />
</Provider>
);
}// клиентская гидрация
import { Provider } from 'effector-react';
import { fork } from 'effector';
const scope = fork({
values: window.__INITIAL_STATE__,
});
hydrateRoot(
document.getElementById('root')!,
<Provider value={scope}>
<App />
</Provider>
);Тестирование
Effector легко тестировать благодаря fork - каждый тест получает свой изолированный scope.
import { fork, allSettled } from 'effector';
describe('auth model', () => {
test('login success updates user store', async () => {
const scope = fork({
handlers: [
// мокаем эффект
[loginFx, async () => ({ user: { id: '1', name: 'Alice' }, token: 'abc' })],
],
});
await allSettled(loginFx, {
scope,
params: { email: 'alice@example.com', password: '123' },
});
expect(scope.getState($user)).toEqual({ id: '1', name: 'Alice' });
expect(scope.getState($token)).toBe('abc');
});
test('login failure sets error', async () => {
const scope = fork({
handlers: [
[loginFx, async () => { throw new Error('Invalid credentials'); }],
],
});
await allSettled(loginFx, {
scope,
params: { email: 'wrong@example.com', password: 'wrong' },
});
expect(scope.getState($user)).toBeNull();
expect(scope.getState($error)).toBe('Invalid credentials');
});
test('reset clears all state', async () => {
const scope = fork({
values: [
[$user, { id: '1', name: 'Alice' }],
[$token, 'abc'],
],
});
await allSettled(logout, { scope });
expect(scope.getState($user)).toBeNull();
expect(scope.getState($token)).toBeNull();
});
});Паттерны из реальных проектов
Модель страницы
import { createEvent, createEffect, createStore, sample } from 'effector';
import { createGate } from 'effector-react';
// gate для жизненного цикла страницы
export const PostsPageGate = createGate<void>();
// события
export const pageOpened = createEvent();
export const searchChanged = createEvent<string>();
export const sortChanged = createEvent<'date' | 'title'>();
// эффекты
export const fetchPostsFx = createEffect<FetchParams, Post[], Error>(
async (params) => {
const query = new URLSearchParams(params as Record<string, string>);
const res = await fetch(`/api/posts?${query}`);
return res.json();
}
);
// сторы
export const $posts = createStore<Post[]>([]);
export const $search = createStore('');
export const $sort = createStore<'date' | 'title'>('date');
export const $isLoading = fetchPostsFx.pending;
// связи
$posts.on(fetchPostsFx.doneData, (_, posts) => posts);
$search.on(searchChanged, (_, value) => value);
$sort.on(sortChanged, (_, value) => value);
// загрузка при открытии страницы
sample({
clock: PostsPageGate.open,
source: { search: $search, sort: $sort },
target: fetchPostsFx,
});
// перезагрузка при изменении фильтров
sample({
clock: [searchChanged, sortChanged],
source: { search: $search, sort: $sort },
target: fetchPostsFx,
});CRUD-модель сущности
import { createEvent, createEffect, createStore, sample } from 'effector';
// эффекты
const fetchTodosFx = createEffect<void, Todo[]>(
async () => (await fetch('/api/todos')).json()
);
const createTodoFx = createEffect<CreateTodoDTO, Todo>(
async (dto) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
});
return res.json();
}
);
const deleteTodoFx = createEffect<string, string>(
async (id) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
return id;
}
);
const toggleTodoFx = createEffect<string, Todo>(
async (id) => {
const res = await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' });
return res.json();
}
);
// стор
const $todos = createStore<Todo[]>([])
.on(fetchTodosFx.doneData, (_, todos) => todos)
.on(createTodoFx.doneData, (todos, newTodo) => [...todos, newTodo])
.on(deleteTodoFx.doneData, (todos, id) => todos.filter((t) => t.id !== id))
.on(toggleTodoFx.doneData, (todos, updated) =>
todos.map((t) => (t.id === updated.id ? updated : t))
);
const $todosCount = $todos.map((t) => t.length);
const $completedCount = $todos.map((t) => t.filter((x) => x.completed).length);