Модуль 8. Внедрение Effector (рефакторинг)

Цель модуля: показать ценность Effector через рефакторинг работающего MentionInput из М7. Прямой fetch + 4 useState + setTimeout + AbortController → декларативный граф связей Effector.

К концу модуля у вас:

  • Сторы $mentionValue, $suggestions, $status, $error на Effector.
  • Эффект fetchMentionsFx с debounce через sample + attach.
  • Хук useMentionAutocomplete — тонкая React-обвязка через useUnit.
  • Unit-тесты стора через effector/testing (3 сценария в 20 строках).

📌 Перед началом: Модули 3, 7. 🛠 Инструменты: Effector, effector/testing. ⏱ Время: ~5 ч.

🔥 Инцидент: MentionInput невозможно протестировать

Виджет MentionInput делает прямой fetch в useEffect. Сторобук — состояние в useState. Тесты невозможны (нужен HTTP mock на уровне fetch). При SSR — hydrate mismatch. Effector решает всё: сторы — чистые данные, эффекты — изолированный I/O, fork() даёт детерминированные тесты без рендера.

📖 Теория

8.1 Сторы и эффекты

ts
// app/features/mention-autocomplete/model.ts
import { createEffect, createEvent, createStore, sample, attach } from 'effector'
import { fetchMentions, type UserSuggestion } from '@/shared/api/client'

type Status = 'idle' | 'loading' | 'done' | 'error'

// --- Stores ---
export const $mentionValue = createStore('')
export const $suggestions = createStore<UserSuggestion[]>([])
export const $status = createStore<Status>('idle')
export const $error = createStore<string | null>(null)

// --- Events ---
export const mentionValueChanged = createEvent<string>()
export const suggestionSelected = createEvent<UserSuggestion>()
export const clearSuggestions = createEvent()

// --- Effect ---
export const fetchMentionsFx = createEffect<string, UserSuggestion[], Error>(
  async (q) => (await fetchMentions(q)).suggestions
)

// --- Связи: event → store ---
$mentionValue.on(mentionValueChanged, (_, q) => q)
$suggestions
  .on(fetchMentionsFx.doneData, (_, data) => data)
  .on(clearSuggestions, () => [])
$error
  .on(fetchMentionsFx.fail, (_, e) => e.error.message)
  .on(fetchMentionsFx.doneData, () => null)
$status
  .on(mentionValueChanged, () => 'loading')
  .on(fetchMentionsFx.doneData, () => 'done')
  .on(fetchMentionsFx.fail, () => 'error')
  .on(clearSuggestions, () => 'idle')

// --- Debounce: mentionValueChanged → 300ms → fetchMentionsFx ---
const debouncedQuery = createEvent<string>()

// Внутренний эффект задержки
const delayFx = createEffect(async (q: string): Promise<string> => {
  await new Promise((r) => setTimeout(r, 300))
  return q
})

// mentionValueChanged → delayFx (перезапускает таймер каждый раз)
sample({ clock: mentionValueChanged, target: delayFx })
// delayFx.done → debouncedQuery
sample({ clock: delayFx.doneData, target: debouncedQuery })

// debouncedQuery → fetchMentionsFx (только если query ≥ 2 символов)
sample({
  clock: debouncedQuery,
  filter: (q) => q.length >= 2,
  target: fetchMentionsFx,
})

// Очистка при коротком query
sample({
  clock: debouncedQuery,
  filter: (q) => q.length < 2,
  target: clearSuggestions,
})

8.2 Сравнение: прямой fetch vs Effector

АспектМ7 (прямой fetch)М8 (Effector)
Состояние4 useState4 createStore — декларативные связи
DebouncesetTimeout в useEffectsample + delayFx — чистый граф
Отмена запросаAbortController вручнуюEffector автоматически (latest wins через sample)
ТестированиеНужен RTL + jsdomeffector/testing, без рендера
SSRСтор в RSC ломает гидрациюСтор в Client Component — безопасно

8.3 React-обвязка: хук useMentionAutocomplete

tsx
// app/features/mention-autocomplete/useMentionAutocomplete.ts
'use client'

import { useUnit } from 'effector-react'
import { $mentionValue, $suggestions, $status, $error, mentionValueChanged, suggestionSelected } from './model'

export function useMentionAutocomplete() {
  const [value, suggestions, status, error, onChange, onSelect] = useUnit([
    $mentionValue,
    $suggestions,
    $status,
    $error,
    mentionValueChanged,
    suggestionSelected,
  ])

  return { value, suggestions, status, error, onChange, onSelect }
}
tsx
// app/features/mention-autocomplete/MentionInputEffector.tsx
'use client'

import { Input } from '@myorg/ui-kit'
import { useMentionAutocomplete } from './useMentionAutocomplete'

export function MentionInputEffector() {
  const { value, suggestions, status, error, onChange } = useMentionAutocomplete()

  return (
    <div>
      <Input
        id="mention-eff"
        placeholder="Type @ to mention (Effector)..."
        value={value}
        onChange={(e) => onChange(e.target.value)}
        loading={status === 'loading'}
        error={error ?? undefined}
      />
      {status === 'done' && suggestions.length > 0 && (
        <ul role="listbox">
          {suggestions.map((s) => (
            <li key={s.id} role="option">{s.name}</li>
          ))}
        </ul>
      )}
    </div>
  )
}

Заметили? Компонент стал 15 строк вместо 60. Вся логика — в model.ts, компонент только отображает сторы.


8.4 Тесты через effector/testing

ts
// app/features/mention-autocomplete/model.test.ts
import { describe, it, expect, vi } from 'vitest'
import { fork, allSettled } from 'effector'
import { $mentionValue, $suggestions, $status, mentionValueChanged, fetchMentionsFx } from './model'

// Мокаем эффект — не делаем реальный fetch
vi.mock('@/shared/api/client', () => ({
  fetchMentions: vi.fn().mockResolvedValue({
    suggestions: [
      { id: '1', name: 'Alice', avatar: null },
      { id: '2', name: 'Bob', avatar: null },
    ],
  }),
}))

describe('mention autocomplete store', () => {
  it('updates value on mentionValueChanged', async () => {
    const scope = fork()
    await allSettled(mentionValueChanged, { scope, params: 'Al' })
    expect(scope.getState($mentionValue)).toBe('Al')
  })

  it('fetches suggestions after debounce', async () => {
    const scope = fork()
    await allSettled(mentionValueChanged, { scope, params: 'Alice' })
    // Ждём, пока эффект отработает (debounce + fetch)
    await vi.waitFor(() => {
      expect(scope.getState($suggestions)).toHaveLength(2)
    })
    expect(scope.getState($status)).toBe('done')
  })

  it('clears on short query', async () => {
    const scope = fork()
    await allSettled(mentionValueChanged, { scope, params: 'A' })
    await vi.waitFor(() => {
      expect(scope.getState($suggestions)).toHaveLength(0)
    })
    expect(scope.getState($status)).toBe('idle')
  })
})

3 теста, ~25 строк, 0 компонентов, 0 RTL. Стор тестируется как чистая функция.


8.5 SSR-безопасность (продолжение М3)

Сторы живут только в Client Components. Route Handler — серверный. Контракт (Zod-схема) — общий:

[Route Handler (сервер)] → fetchMentions (shared/api/client.ts) → Zod-валидация
                                    ↑
                       [MentionInputEffector (Client Component)]
                                    ↑
                        useUnit($suggestions) — только в 'use client'

Компромиссы (Trade-offs)

РешениеПлюсыМинусы
Effector (вместо 4 useState)Тесты без RTL, SSR-forkКривая обучения
sample + delayFx для debounceЧистый граф, без setTimeout в effectЭффект задержки в графе
Отмена через latest-wins sampleБез ручного AbortControllerСтарые запросы всё летят на сервер
Хук useMentionAutocompleteТонкая view-обвязкаДополнительная прослойка

💻 8.6 Практика

  1. Создайте app/features/mention-autocomplete/model.ts по образцу выше.
  2. Реализуйте хук useMentionAutocomplete и компонент MentionInputEffector.
  3. Напишите тесты через effector/testing.
  4. Добавьте MentionInputEffector на ui-gallery.
  5. Сравните с MentionInput из М7 — тот же UX, но код чище.
bash
npm run verify

8.7 Артефакты сдачи М8

АртефактКритерий
verify зелёный3 unit-теста стора проходят
Оба MentionInput работаютНа ui-gallery — прямая и Effector-версия рядом, одинаковый UX
Код-ревьюСтор в features/, не в UI Kit; ESLint не ругается на FSD-импорты

Вперёд → Модуль 9: реакции, эмодзи, виртуализация.


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

1. Effector split (event → condition gates) — аналог чего в Redux?
2. Рефакторинг на Effector — главное преимущество?
3. createDomain() — зачем при рефакторинге?
4. Migration Redux→Effector: главное преимущество для тестов?
5. fork() при тестировании Effector — что даёт?

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

Рефакторинг завершён: 60 строк прямого fetch превратились в 15 строк компонента + декларативный граф. Теперь добавим реакции и виртуализацию списков — чтобы 1000 сообщений не тормозили чат.