Модуль 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).

Поток данных в Effector: событие → sample → эффект → стор → React

Поток данных в модели: UI вызывает событие, событие через .on меняет стор или через sample запускает эффект (единственный канал I/O), результат эффекта (doneData / fail / pending) кладётся в сторы, а React подписывается на них через useUnit. Стор — только значение: ни setter'а, ни прямых мутаций из компонента.

Store — статичное значение

ts
import { 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 — триггер

ts
import { 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 — сайд-эффект

ts
import { 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:

ts
import { 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, но для сторов.

ts
import { 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 ToolkitZustandEffector
Тестируемость без рендераСложно (нужен mock store, provider)Средне (нужен act)Идеально (effector/testing, стор тестируется как чистая функция)
SSRЧерез next-redux-wrapperРучное управлениеВстроено (fork/allSettled)
Изоляция единицReducer — монолитStore = всё-в-одномГраф (каждый store/event/effect — самостоятельная единица)
АсинхронностьcreateAsyncThunk + middlewareРучной asyncНативно (createEffect с .pending/.done/.fail)
DevToolsRedux 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 ComponentRSC не имеет 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. Проверка

bash
npm run verify   # всё зелёное, включая 3 новых теста

3.8 Артефакты сдачи М3

АртефактКак получитьКритерий
verify зелёныйnpm run verify3 теста modal store проходят
model.test.tsnpm 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.


🧩 Проверь себя

1. Effector combine — для чего?
2. Effect (createEffect) — отличие от event?
3. Effector attach (createEffect + attach) — зачем?
4. Event (createEvent) — для чего?
5. Effector store (createStore) — это...

🔗 Мостик к Модулю 4

Стор на Effector работает: модалка и тесты через effector/testing. Теперь применим его к формам ввода — Input, Textarea, Select, Checkbox — и построим стор тостов на этой же модели.