Модуль 3. Основы Effector (теоретический фундамент)
Цель модуля: дать системную картину модели Effector до того, как вы встретите эффекты в сложных кейсах (М8, М12). Middle-разработчик, впервые видящий Effector, нуждается в теории, а не в «знакомстве на модалке».
К концу модуля у вас:
- Понимание четырёх сущностей Effector: store / event / effect / sample.
- Рабочий стор «модалка» с unit-тестами через
effector/testing— без рендера React. - Осознание, почему сторы живут только в Client Components Next.js App Router.
- Сравнение Effector с Redux/Zustand — когда и почему Effector сильнее.
Где живёт код этого модуля: сторы и эффекты — в
app/features/иapp/entities/(FSD). UI Kit остаётся нейтральным, без единого импорта effector (проверяется ESLint, см. М1 §0.10).
📌 Перед началом: Модули 1-2. 🛠 Инструменты: Effector, effector-react. ⏱ Время: ~6 ч.
🔥 Инцидент: дребезг автокомплита
Пользователь ищет контакт в мессенджере. Каждый keystroke → fetch API. 10 букв = 10 запросов. Последний ответ приходит первым — на экране «дребезг» (flicker). Cancel token не настроен, useEffect гонки. Нужен Effector: sample + attach дают debounce и отмену in-flight запросов декларативно, без ручных AbortController.
📖 Теория
3.1 Модель Effector: четыре сущности
Effector — это не «React context с редьюсерами». Это самостоятельный рантайм графов вычислений, который существует вне React. React — лишь один из возможных «рендереров» (через effector-react).
Поток данных в модели: UI вызывает событие, событие через .on меняет
стор или через sample запускает эффект (единственный канал I/O), результат
эффекта (doneData / fail / pending) кладётся в сторы, а React
подписывается на них через useUnit. Стор — только значение: ни setter'а, ни
прямых мутаций из компонента.
Store — статичное значение
tsimport { createStore } from 'effector' // $-префикс — конвенция Effector для stores. const $count = createStore(0) const $user = createStore<{ id: string; name: string } | null>(null)
Стор — это только значение и набор реактивных связей. У него нет setter'а. Его нельзя мутировать напрямую ($count.setState(5) — нельзя, если только не через createStore(0, { updateFilter })). Значение меняется только через events и effects.
Event — триггер
tsimport { createEvent } from 'effector' const incremented = createEvent<void>() const nameChanged = createEvent<string>() // Связка: event → store $count.on(incremented, (state) => state + 1) $user.on(nameChanged, (state, name) => state ? { ...state, name } : state)
Event — это функция. Вызов incremented() триггерит все привязанные сторы. События — это «команды», а стор — «состояние системы после команд».
Effect — сайд-эффект
tsimport { createEffect } from 'effector' // Эффект — это асинхронная функция с тремя производными stores: // .pending (boolean), .done (событие успеха), .fail (событие ошибки) const fetchUserFx = createEffect(async (userId: string) => { const res = await fetch(`/api/users/${userId}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() as Promise<{ id: string; name: string }> }) // Связка: event → effect → store $nameChanged.watch((name) => console.log('name changed:', name)) $user.on(fetchUserFx.doneData, (_, user) => user) // Вызов: await fetchUserFx('123') // → обновит $user, .pending переключится true → false
Эффекты — единственный канал для I/O: fetch, localStorage, setTimeout. Компоненты и сторы не делают I/O напрямую.
sample — связка потоков
sample — самая мощная операция Effector. Берёт значение из source в момент срабатывания clock и передаёт в target:
tsimport { sample, createStore, createEvent } from 'effector' const $query = createStore('') const debounced = createEvent<string>() // При срабатывании `debounced` — берём текущее значение $query и передаём в searchFx sample({ source: $query, clock: debounced, filter: (query) => query.length >= 2, // только если query ≥ 2 символов target: fetchSuggestionsFx, })
sample — это «связь по событию, а не по значению». Он решает, когда взять source и куда отправить. Без sample вы бы писали useEffect с зависимостями — а это хаос.
Другие операции (обзорно)
| Операция | Что делает | Когда нужна |
|---|---|---|
attach | Параметризация эффекта: берёт данные из store и передаёт в effect как аргумент | М8: debounce, retry, пагинация |
guard | Пропускает event дальше только если условие true | Защита от двойного клика, if (canSubmit) |
combine | Производный store из нескольких stores | $isFormValid = combine($email, $password, ...) |
merge | Объединяет несколько events в один | «Любая из трёх кнопок закрывает модалку» |
split | Маршрутизация event по условию в разные targets | М8: «пустой query → clear, валидный → fetch» |
forward | Простой мост event→event (deprecated в пользу sample) | — |
Главный shift мышления: в Redux вы пишете reducer (
switch case). В Effector вы строите граф связей ($store.on(event, fn),sample(...)). Граф — это данные, его можно тестировать без рендера, форкать для SSR, реверсить в devtools.
3.2 Эффект с debounce и отменой — полный пример
ts// app/features/search/model.ts import { createEffect, createEvent, createStore, sample, attach } from 'effector' // --- Stores --- const $query = createStore('') const $suggestions = createStore<string[]>([]) const $status = createStore<'idle' | 'loading' | 'done' | 'error'>('idle') // --- Events --- const queryChanged = createEvent<string>() const debounced = createEvent<string>() // --- Effect --- const fetchSuggestionsFx = createEffect(async (q: string): Promise<string[]> => { const res = await fetch(`/api/mentions?q=${encodeURIComponent(q)}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() return data.suggestions as string[] }) // --- Связи --- $query.on(queryChanged, (_, q) => q) $status.on(queryChanged, () => 'loading') $suggestions.on(fetchSuggestionsFx.doneData, (_, data) => data) $status.on(fetchSuggestionsFx.doneData, () => 'done') $status.on(fetchSuggestionsFx.fail, () => 'error') // Debounce: queryChanged → 300ms → debounced → sample → fetchSuggestionsFx sample({ source: $query, clock: queryChanged, target: attach({ source: $query, effect: createEffect(async (q: string) => { await new Promise((r) => setTimeout(r, 300)) // debounce 300ms return q }), }), }) sample({ source: $query, clock: debounced, filter: (q) => q.length >= 2, target: fetchSuggestionsFx, })
Этот пример — заготовка. В М8 мы перепишем автокомплит упоминаний через этот паттерн, добавив
AbortControllerдля отмены in-flight запросов.
3.3 Effector и Next.js App Router: SSR-нюансы
Это критически важный раздел. Ошибки здесь ломают гидрацию.
Правило: сторы — только в Client Components
tsx// ❌ НАРУШЕНИЕ — app/app/page.tsx (Server Component по умолчанию) import { useUnit } from 'effector-react' // effector в RSC! import { $count } from '@/features/counter/model' // стор в RSC! export default function Page() { const count = useUnit($count) // TypeError: useEffect is not defined return <div>{count}</div> }
tsx// ✅ ПРАВИЛЬНО — app/features/counter/view.tsx 'use client' // ← Client Component import { useUnit } from 'effector-react' import { $count, incremented } from './model' export function Counter() { const count = useUnit($count) return <button onClick={() => incremented()}>{count}</button> }
tsx// app/app/page.tsx (Server Component) — импортирует Client Component import { Counter } from '@/features/counter/view' export default function Page() { return ( <div> <h1>Счётчик</h1> <Counter /> {/* Client Component — сторы внутри, RSC чист */} </div> ) }
Почему так?
Server Components не имеют lifecycle (useEffect, useState). Effector-сторы через useUnit используют useSyncExternalStore — это клиентский хук. В RSC он не определён → TypeError.
useUnit, а не useStore/useEvent
tsx// ❌ Устаревший API (effector-react < 22) const count = useStore($count) const inc = useEvent(incremented) // ✅ Современный API (effector-react ≥ 22) const [count, inc] = useUnit([$count, incremented]) // или const count = useUnit($count) const inc = useUnit(incremented)
useUnit — единая точка подписки. В одном вызове можно получить и стор, и event. Это позволяет Effector батчить обновления и избегать лишних ререндеров.
SSR-fork: .fork и allSettled (обзор)
Effector поддерживает scope isolation: на сервере создаётся изолированный scope, в нём выполняются эффекты, результат сериализуется клиенту. Это аналог SSR в Next.js, но для сторов.
tsimport { fork, allSettled } from 'effector' // На сервере: создать scope, выполнить эффекты, передать значения клиенту const scope = fork() await allSettled(fetchUserFx, { scope, params: '123' }) const snapshot = scope.getState($user) // { id: '123', name: 'Alice' }
В курсе мы не углубляемся в полноценный SSR-scope (Next.js App Router + RSC решает большую часть SSR без ручного форка). Но знать, что Effector это умеет — важно для понимания архитектуры.
3.4 Почему Effector, а не Redux/Zustand
| Критерий | Redux Toolkit | Zustand | Effector |
|---|---|---|---|
| Тестируемость без рендера | Сложно (нужен mock store, provider) | Средне (нужен act) | Идеально (effector/testing, стор тестируется как чистая функция) |
| SSR | Через next-redux-wrapper | Ручное управление | Встроено (fork/allSettled) |
| Изоляция единиц | Reducer — монолит | Store = всё-в-одном | Граф (каждый store/event/effect — самостоятельная единица) |
| Асинхронность | createAsyncThunk + middleware | Ручной async | Нативно (createEffect с .pending/.done/.fail) |
| DevTools | Redux DevTools | Ограничены | Effector DevTools (визуализация графа связей) |
| Размер бандла | ~16 KB | ~3 KB | ~8 KB (core) + ~2 KB (react) |
| Boilerplate | Высокий (слайсы, типизация) | Минимальный | Средний (явные связи, но типизированные) |
Когда выбирать Effector:
- Проект с сложным стейтом (debounce, отмена запросов, derived state, очереди).
- Нужна тестируемость сторов без компонентов (unit-тесты 5 строк).
- SSR с изоляцией скоупа.
- Несколько «источников правды» (API + локальное состояние + optimistic updates).
Когда достаточно Zustand:
- Простое клиентское состояние (открыта/закрыта модалка, текущая вкладка).
- Нет сложных асинхронных сценариев.
- Команда не знакома с Effector.
В курсе мы используем оба: Zustand НЕ используем. Но вы должны понимать, почему в сложных кейсах (М8 автокомплит, М12 офлайн-очередь) Effector выигрывает.
3.5 Тестирование сторов через effector/testing
Главная суперсила Effector: сторы тестируются без React, без RTL, без jsdom.
ts// app/features/modal/model.test.ts import { describe, it, expect } from 'vitest' import { fork, allSettled } from 'effector' import { $isOpen, openModal, closeModal, toggleModal } from './model' describe('modal store', () => { it('opens on openModal event', async () => { const scope = fork() // изолированный scope expect(scope.getState($isOpen)).toBe(false) // начальное состояние await allSettled(openModal, { scope }) // триггерим event expect(scope.getState($isOpen)).toBe(true) // проверяем результат }) it('closes on closeModal event', async () => { const scope = fork({ values: [[$isOpen, true]] }) // стартуем с открытым await allSettled(closeModal, { scope }) expect(scope.getState($isOpen)).toBe(false) }) it('toggles', async () => { const scope = fork() await allSettled(toggleModal, { scope }) expect(scope.getState($isOpen)).toBe(true) await allSettled(toggleModal, { scope }) expect(scope.getState($isOpen)).toBe(false) }) })
3 теста, 15 строк, 0 компонентов, 0 моков. Это невозможно в Redux без создания mock-store и провайдера.
3.6 Антипаттерны
| Антипаттерн | Почему плохо | Как правильно |
|---|---|---|
useEffect для подписки на стор | Двойная подписка, гонка рендеров | useUnit($store) — он подписывается сам |
createStore внутри компонента | Новый стор на каждый рендер | Стор на уровне модуля, компонент только useUnit |
createStore в Server Component | RSC не имеет lifecycle, useSyncExternalStore не определён | Только в Client Component ('use client') |
Мутация стора напрямую ($count.setState(5)) | Обход графа связей, реактивность ломается | Только через event/effect |
useStore/useEvent (старый API) | deprecated, лишние ререндеры | useUnit($store) / useUnit(event) |
| Бизнес-логика в компоненте | Не тестируется, не переиспользуется | Сторы и связки — в features/*/model.ts |
| стор в UI Kit | Нарушение нейтральности (см. М1) | Сторы — в app/features/, UI Kit — на пропсах |
Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
| Effector (вместо Redux/Zustand) | Тесты без рендера, SSR-fork, граф связей | Кривая обучения, свой API |
| Сторы вне UI Kit | Нейтральность, переиспользование | Больше протаскиваний через props/view |
useUnit (не useStore/useEvent) | Батчинг, меньше ререндеров | Новый API после effector-react 22 |
| Сторы только в Client Component | Гидрация не ломается | RSC нельзя напрямую со стором |
💻 3.7 Практика
Шаг 1. Установить Effector (уже в boilerplate)
bash# effector и effector-react уже в app/package.json node -e "console.log(require('./app/package.json').dependencies)" | grep effector
Шаг 2. Написать стор «модалка»
ts// app/features/modal/model.ts import { createEvent, createStore } from 'effector' export const $isOpen = createStore(false) export const openModal = createEvent<void>() export const closeModal = createEvent<void>() export const toggleModal = createEvent<void>() $isOpen.on(openModal, () => true) $isOpen.on(closeModal, () => false) $isOpen.on(toggleModal, (state) => !state)
Шаг 3. Написать тесты
Создайте app/features/modal/model.test.ts по образцу из §1.5.5.
Шаг 4. Подключить стор к React
tsx// app/features/modal/view.tsx 'use client' import { useUnit } from 'effector-react' import { $isOpen, closeModal, toggleModal } from './model' import { Button, Text, Box } from '@myorg/ui-kit' export function ModalToggle() { const isOpen = useUnit($isOpen) const toggle = useUnit(toggleModal) const close = useUnit(closeModal) return ( <Box direction="column" gap="md" padding="md"> <Text variant="h3">Modal: {isOpen ? 'Open' : 'Closed'}</Text> <Box gap="sm"> <Button variant="primary" onClick={toggle}> Toggle </Button> {isOpen && ( <Button variant="ghost" onClick={close}> Close </Button> )} </Box> </Box> ) }
Шаг 5. Визуальная проверка
Добавьте <ModalToggle /> на страницу app/app/ui-gallery/page.tsx и проверьте в браузере.
Шаг 6. Проверка
bashnpm run verify # всё зелёное, включая 3 новых теста
3.8 Артефакты сдачи М3
| Артефакт | Как получить | Критерий |
|---|---|---|
verify зелёный | npm run verify | 3 теста modal store проходят |
model.test.ts | npm run test | Видны 3 кейса: open, close, toggle |
| Скриншот ModalToggle | :3000/ui-gallery | Кнопка Toggle меняет состояние |
| ESLint проверка | npm run lint | Импорт effector в UI Kit — НЕ найден (правило работает) |
3.9 Чек-лист перед Модулем 4
- Я могу объяснить разницу между store, event и effect.
- Я написал стор «модалка» с тремя событиями.
- Я протестировал стор через
effector/testingбез рендера компонентов. - Я подключил стор к React через
useUnitв Client Component. - Я понимаю, почему нельзя вызывать
createStoreв Server Component. - Я знаю, чем
sampleотличается отuseEffectс зависимостями.
Вперёд → Модуль 4: формы и ранний Effector.
🧩 Проверь себя
🔗 Мостик к Модулю 4
Стор на Effector работает: модалка и тесты через effector/testing. Теперь применим его к формам ввода — Input, Textarea, Select, Checkbox — и построим стор тостов на этой же модели.