Модуль 8. Внедрение Effector (рефакторинг)
Цель модуля: показать ценность Effector через рефакторинг работающего
MentionInputиз М7. Прямойfetch+ 4useState+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 useState | 4 createStore — декларативные связи |
| Debounce | setTimeout в useEffect | sample + delayFx — чистый граф |
| Отмена запроса | AbortController вручную | Effector автоматически (latest wins через sample) |
| Тестирование | Нужен RTL + jsdom | effector/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 Практика
- Создайте
app/features/mention-autocomplete/model.tsпо образцу выше. - Реализуйте хук
useMentionAutocompleteи компонентMentionInputEffector. - Напишите тесты через
effector/testing. - Добавьте
MentionInputEffectorнаui-gallery. - Сравните с
MentionInputиз М7 — тот же UX, но код чище.
bashnpm run verify
8.7 Артефакты сдачи М8
| Артефакт | Критерий |
|---|---|
verify зелёный | 3 unit-теста стора проходят |
| Оба MentionInput работают | На ui-gallery — прямая и Effector-версия рядом, одинаковый UX |
| Код-ревью | Стор в features/, не в UI Kit; ESLint не ругается на FSD-импорты |
Вперёд → Модуль 9: реакции, эмодзи, виртуализация.
🧩 Проверь себя
🔗 Мостик к Модулю 9
Рефакторинг завершён: 60 строк прямого fetch превратились в 15 строк компонента + декларативный граф. Теперь добавим реакции и виртуализацию списков — чтобы 1000 сообщений не тормозили чат.