Перейти к содержимому

67. Effector + React

Effector + React — связываем логику с интерфейсом

Заголовок раздела «Effector + React — связываем логику с интерфейсом»

В Effector логика живёт снаружи React в чистых юнитах (сторах, событиях, эффектах). А пакет effector-react предоставляет хуки для их подключения к компонентам. Это как розетка в стене 🔌: розетка — это компонент, проводка — это Effector.

Окно терминала
npm install effector effector-react
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>;
}
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 — новый универсальный хук, заменяющий 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 — это специальный компонент, который позволяет синхронизировать монтирование/размонтирование 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>;
}

По умолчанию 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>
);
}
server.ts
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>
);

Полный пример: счётчик + профиль пользователя

Заголовок раздела «Полный пример: счётчик + профиль пользователя»
model.ts
import { createStore, createEvent, createEffect, sample, combine } from 'effector';
// Counter
export const incremented = createEvent();
export const decremented = createEvent();
export const $count = createStore(0)
.on(incremented, s => s + 1)
.on(decremented, s => s - 1);
// User
export 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 state
export const $appState = combine({
count: $count,
profile: $profile,
isLoading: $isLoading,
});
App.tsx
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().


Попробуйте примеры в интерактивном редакторе: