67. Effector + React
Effector + React — связываем логику с интерфейсом
Заголовок раздела «Effector + React — связываем логику с интерфейсом»В Effector логика живёт снаружи React в чистых юнитах (сторах, событиях, эффектах). А пакет effector-react предоставляет хуки для их подключения к компонентам. Это как розетка в стене 🔌: розетка — это компонент, проводка — это Effector.
Установка
Заголовок раздела «Установка»npm install effector effector-reactuseStore() — читаем стор в компоненте
Заголовок раздела «useStore() — читаем стор в компоненте»import { createStore } from 'effector';import { useStore } from 'effector-react';
const $count = createStore(0);const $user = createStore<User | null>(null);
function Counter() { // Компонент перерендерится только при изменении $count const count = useStore($count); return <span>{count}</span>;}
function UserGreeting() { const user = useStore($user); if (!user) return <span>Гость</span>; return <span>Привет, {user.name}!</span>;}useEvent() — вызываем события из компонентов
Заголовок раздела «useEvent() — вызываем события из компонентов»import { createEvent } from 'effector';import { useEvent } from 'effector-react';
const incremented = createEvent();const nameChanged = createEvent<string>();
function Controls() { // useEvent оборачивает событие для безопасного вызова внутри React const increment = useEvent(incremented); const changeName = useEvent(nameChanged);
return ( <div> <button onClick={increment}>+1</button> <input onChange={e => changeName(e.target.value)} /> </div> );}useUnit() — современный API (Effector 23+)
Заголовок раздела «useUnit() — современный API (Effector 23+)»useUnit — новый универсальный хук, заменяющий useStore + useEvent:
import { useUnit } from 'effector-react';
function TodoApp() { // Передаём объект с юнитами — получаем объект с результатами const { todos, // из $todos (Store) isLoading, // из fetchTodosFx.pending (Store) addTodo, // из todoAdded (Event) removeTodo, // из todoRemoved (Event) fetchTodos, // из fetchTodosFx (Effect) } = useUnit({ todos: $todos, isLoading: fetchTodosFx.pending, addTodo: todoAdded, removeTodo: todoRemoved, fetchTodos: fetchTodosFx, });
return ( <div> {isLoading ? <Spinner /> : <TodoList todos={todos} onRemove={removeTodo} />} <button onClick={() => addTodo('Новая задача')}>Добавить</button> </div> );}Gate — жизненный цикл страницы
Заголовок раздела «Gate — жизненный цикл страницы»Gate — это специальный компонент, который позволяет синхронизировать монтирование/размонтирование React-компонента с Effector. Очень удобно для загрузки данных при открытии страницы!
import { createGate } from 'effector-react';import { sample } from 'effector';
// Создаём Gate с опциональным типом пропсовconst UserPageGate = createGate<{ userId: number }>();
// Реагируем на открытие страницыsample({ clock: UserPageGate.open, // когда компонент монтируется source: UserPageGate.state, // берём props из Gate fn: ({ userId }) => userId, target: fetchUserFx,});
// Реагируем на закрытие страницыsample({ clock: UserPageGate.close, target: resetUserData,});// Использование в компонентеfunction UserPage({ userId }: { userId: number }) { // Gate автоматически откроется при монтировании и закроется при размонтировании return ( <> <UserPageGate userId={userId} /> <UserContent /> </> );}
// Можно читать состояние Gate через хукfunction UserContent() { const { isOpen } = useGate(UserPageGate); // isOpen === true пока компонент смонтирован return <div>Страница {isOpen ? 'открыта' : 'закрыта'}</div>;}Provider и Scope — изоляция состояния
Заголовок раздела «Provider и Scope — изоляция состояния»По умолчанию Effector использует глобальное состояние. Для SSR, тестов и микрофронтендов нужна изоляция через Scope:
import { fork, allSettled, serialize } from 'effector';import { Provider } from 'effector-react';
// Создаём изолированный экземпляр (scope)const scope = fork({ values: [ [$user, preloadedUser], [$todos, preloadedTodos], ],});
// Оборачиваем приложениеfunction App() { return ( <Provider value={scope}> <AppRoutes /> </Provider> );}SSR с Fork API
Заголовок раздела «SSR с Fork API»import { fork, allSettled, serialize } from 'effector';import { renderToString } from 'react-dom/server';
async function handleRequest(userId: number) { // Создаём изолированный scope для каждого запроса const scope = fork();
// Запускаем эффекты в этом scope (не затрагивает глобальное состояние!) await allSettled(fetchUserFx, { scope, params: userId }); await allSettled(fetchTodosFx, { scope, params: { userId } });
// Сериализуем состояние для передачи клиенту const storeValues = serialize(scope);
const html = renderToString( <Provider value={scope}> <App /> </Provider> );
return { html, storeValues };}
// client.ts — восстанавливаем состояниеconst scope = fork({ values: window.__STORE_VALUES__ });
hydrateRoot( document.getElementById('root')!, <Provider value={scope}><App /></Provider>);Полный пример: счётчик + профиль пользователя
Заголовок раздела «Полный пример: счётчик + профиль пользователя»import { createStore, createEvent, createEffect, sample, combine } from 'effector';
// Counterexport const incremented = createEvent();export const decremented = createEvent();export const $count = createStore(0) .on(incremented, s => s + 1) .on(decremented, s => s - 1);
// Userexport const fetchProfileFx = createEffect(async () => { const res = await fetch('/api/me'); return res.json();});
export const $profile = createStore(null).on(fetchProfileFx.doneData, (_, u) => u);export const $isLoading = fetchProfileFx.pending;
// Combined stateexport const $appState = combine({ count: $count, profile: $profile, isLoading: $isLoading,});import { useUnit } from 'effector-react';import { $appState, incremented, decremented, fetchProfileFx } from './model';
function App() { const { count, profile, isLoading } = useUnit($appState); const [increment, decrement, fetchProfile] = useUnit([incremented, decremented, fetchProfileFx]);
return ( <div> {isLoading ? <Spinner /> : <Profile user={profile} />} <Counter count={count} onInc={increment} onDec={decrement} /> <button onClick={() => fetchProfile()}>Загрузить профиль</button> </div> );}Лучшие практики
Заголовок раздела «Лучшие практики»[Icon: Layers] Разделяй model и UI: Вся логика — в model.ts, компоненты только отображают и вызывают события.
[Icon: Zap] useUnit вместо useStore/useEvent: Новый универсальный API, работает с батчингом React 18.
[Icon: Shield] Gate для жизненного цикла: Не используй useEffect для загрузки данных при монтировании — используй Gate.
[Icon: Globe] Scope для SSR: Всегда изолируй состояние на сервере через fork().
🔗 Полезные ссылки
Заголовок раздела «🔗 Полезные ссылки»- Effector: Введение
- Effector: Stores и Events
- Effector: Effects
- Effector: Продвинутый уровень
- SSR и Hydration
Практика
Заголовок раздела «Практика»Попробуйте примеры в интерактивном редакторе: