Всем привет! Меня зовут Елизавета Добрянская, я frontend-разработчик в компании ДомКлик. Моя команда занимается разработкой сервисов, предназначенных для коммуникаций с клиентом.
В этой статье я поделюсь своим кратким обзором внедрения стейт-менеджера Effector в продуктовый проект на стеке React + TypeScript, а также покажу на примере, как легко это можно сделать.
Содержание:
-
Немного предыстории
-
Первая встреча с Effector
-
Боль как начало
-
Выходим на новый уровень — получаем удовольствие
-
Best practices
-
Итоги
-
Вместо послесловия
Немного предыстории
Моя команда занимается разработкой разных видов сервисов коммуникаций — отдельных виджетов, npm-пакетов, SSR, полностраничных сайтов. У всех этих продуктов есть одно важное требование: интерфейс должен быстро реагировать на действия пользователя, при этом сам сервис должен выдерживать большую нагрузку. А это значит, что на нас, как на разработчиках, лежит большая ответственность за то, как мы проектируем frontend.
Перед созданием нового проекта мы с командой устроили брейншторм на предмет выбора стейт-менеджера. Нам было важно, чтобы он стал хорошим помощником в разработке, позволял быстро и удобно писать код, и плюс ко всему не «бил» по производительности нового проекта. Его главной задачей стало сохранение данных на клиенте для дальнейшей модификации и отправки на бек (ничего необычного).
Выбирали между Redux, Mobx и Effector. Первые два мы пробовали, и впечатления остались очень неоднозначные. И как ясно из статьи, выбрали последний, потому что любопытно было узнать, что же за зверь такой этот Effector и чем он может помочь нам. К тому же новый проект создавался для внутренних нужд и на нем вполне можно было поэкспериментировать.
ATTENTION: приведенные в статье размышления являются сугубо субъективными, поэтому ваше мнение может отличаться от моего. Они носят обозревательный характер и позволяют познакомиться с Effector на моем примере.
Все примеры с кодом доступны в тестовом проекте на GitHub, который при необходимости можно запустить и лично познакомиться с Effector.
Первая встреча с Effector
Что есть Effector? Модный, молодежный реактивный стейт-менеджер :) А потому понять его базовые принципы оказалось довольно просто. В его основе лежат три простых базовых сущности:
-
Хранилище (Store) — это место, где мы храним наши данные.
-
Событие (Event) — это действие, которое каким-то образом модифицирует хранилище.
-
Эффект (Effect) — это асинхронное действие, связанное с хранилищем.
У каждой из сущностей есть большое количество различных методов, позволяющих изменять входные/выходные параметры, связывать сущности между собой и выполнять другие крутые штуки.
Основная идея, лежащая в основе Effector — подписка на события. У нас есть хранилище, мы подписываемся на его обновления, вешая определенный обработчик. Например:
// Создаем хранилище, в котором будет лежать массив пользователей
// IUser — интерфейс, описывающий пользователя (имя, фамилия и т.п.)
export const $users = createStore<IUser[]>([]);
// Создаем событие, принимающее параметр IUser
export const update = createEvent<IUser>();
// Обычный хендлер на обновление. Добавляем или изменяем пользователя
const updateStore = (state: IUser[], data: IUser) => {
const userIndex = state.findIndex((user) => user.id === data.id);
// Изменяем стейт
if (userIndex > -1) {
state.splice(userIndex, 1, data);
} else {
state.push(data);
}
// Возвращаем измененный стейт
return [...state];
};
// Подписываемся на событие в хранилище
$users
.on(update, updateStore);
Effector позволяет работать с разными типами приложений, таких как React, React Native, Vue, Node.js. Кроме того, он поддерживает TypeScript.
Для работы с React есть удобный пакет effector-react
, предоставляющий несколько интерфейсов взаимодействия React-компонентов с Effector. Самый простой способ — использовать хук useStore
для максимально лаконичной работы с хранилищами Effector. Вот пример работы с описанным выше хранилищем $users
, где по нажатию на кнопку мы добавляем в хранилище пользователя-заглушку:
import { useStore } from 'effector-react';
import { $users, update } from 'models/users';
export const UserList = () => {
const users = useStore($users);
const mockUser = {
id: 1111,
name: 'Peter',
surname: 'Jonson',
age: 25,
gender: 'male',
};
const usersItems = users.map((user) => (
<div key={user.id}>
<div>Name: {user.name}</div>
<div>Surname: {user.surname}</div>
<div>Age: {user.age}</div>
<div>Gender: {user.gender}</div>
<br/>
</div>
));
return (
<div>
{usersItems}
<button onClick={() => update(mockUser)}>
Add mock user to Effector store
</button>
</div>
);
};
Ради интереса можно попробовать сделать то же самое, но с хуком useList
. Он предоставляет упрощенный вариант взаимодействия с хранилищем-массивом. Реализация аналогичной задачи:
import { useList } from 'effector-react';
import { $users, update } from 'models/users';
export const UserList2 = () => {
// Можно преобразовать в массив нод сразу при подключении.
// Не нужно использовать пропс key, как было с map()
const users = useList($users, (user) => (
<div>
<div>Name: {user.name}</div>
<div>Surname: {user.surname}</div>
<div>Age: {user.age}</div>
<div>Gender: {user.gender}</div>
<br/>
</div>
));
const mockUser = {
id: 2222,
name: 'Diana',
surname: 'Gregory',
age: 22,
gender: 'female',
};
return (
<div>
{users}
<button onClick={() => update(mockUser)}>
Add mock user to Effector store
</button>
</div>
);
};
Об этом и многом другом можно почитать в официальной документации Effector. Поэтому долго не будем на этом останавливаться и перейдем к «самому сладенькому». Далее я расскажу про боли и страдания в процессе работы с этим, казалось бы, очень простым и удобным стейт-менеджером. Без купюр.
Боль как начало
Не стоит думать, что статья про несовершенный, наполненный кучей проблем и багов продукт увидела бы свет. Проблемы, описанные здесь, я встретила будучи полным новичком в Effector. По итогу они были повержены, а автор этой статьи — счастлив :) Если вы встретите похожие проблемы, то можете воспользоваться приведенными решениями или модернизировать их, чтобы создать своё.
1) TypeScript
Да, самым сложным для меня оказалась поддержка такого же модного и молодежного, как и Effector, языка программирования TypeScript. В официальной документации Effector-а все примеры приведены на чистом JavaScript. Есть, конечно, маленькая робкая вкладка "TypeScript", которая, в основном, даёт только понимание того, куда нужно добавить типы в описании основных сущностей, но на этом всё. Поэтому сначала я использовала any
, а под конец пришлось очень много страдать с расстановкой правильных типов (особенно касательно эффектов).
Так, например, родились следующие интерфейсы функций (слабонервным не смотреть):
// Создаем эффекты для получения и изменения данных о пользователях
// IUserPayload - интерфейс пользователя, приходящий с сервера
export const getUsersFx = createEffect<void, IUserPayload[], Error>();
export const updateUserFx = createEffect<
IUserPayload,
IUserPayload,
Error
>();
// Изменяем формат данных из хранилища в формат, необходимый для отправки запроса
const serializeDataBeforeFetch = attach<
IUser,
Store<IUser[]>,
typeof updateUserFx
>({
effect: updateUserFx,
source: $users,
mapParams: (params: IUser, data: IUser[]) => {
const user = data.find((item) => item.id === params.id)!;
const userCopy = { ...user };
delete userCopy?.onlineStatus;
return userCopy;
},
});
Небольшие пояснения по коду.
Эффекты имеют следующий формат типизации:
-
Тип передаваемого в эффект значения.
-
Тип возвращаемого из эффекта значения.
-
Тип ошибки для случая, если что-то пошло не так.
Про функцию serializeDataBeforeFetch
расскажу ниже, а пока стоит обратить внимание на типы метода attach
, предоставляемого Effector:
-
Тип передаваемого значения.
-
Тип данных хранилища.
-
Тип эффекта, используемого внутри
attach
.
2) Асинхронные события
Поначалу было очень сложно это понять и принять. Представьте ситуацию, что вы написали код, и при тестировании он выдает неожиданные результаты и ошибку. Вы пытаетесь отладить ошибкоопасное место с помощью точек останова, но видите, что в дебаг-режиме всё работает, как нужно. А вот в обычном режиме (и на самом деле) всё не так, ничего не работает. То есть в режиме отладки вы как бы «притормаживаете» свой код, и поэтому он отрабатывает корректно, а на самом деле есть проблемы. Собственно, это просто нужно принять к сведению торопливому разработчику — действия в Effector происходят асинхронно (подобно setState
в React).
3) Получение доступа к текущему состоянию
Этот пункт про то, что нужно внимательно смотреть документацию :)
Некоторые методы в Effector могут первым параметром принимать текущее состояние хранилища, а некоторые — нет. Поэтому нужно внимательно выбирать методы обработки.
4) Четкий интерфейс работы с сущностями
Почему это может быть плохо? Потому что сложно отслеживать результат изменения хранилища в рамках связанного компонента. Интерфейс взаимодействия упрощенно выглядит так:
-
Хранилище — readonly. В компоненте мы на него подписываемся, и все изменения считываем реактивно.
-
Событие — по сути, setter. Мы говорим «измени моё хранилище, добавь в него эти данные и удали те». Событие ничего не возвращает. Поэтому его нельзя использовать как getter и получить отфильтрованные данные из хранилища напрямую (об этом будет далее).
-
Эффект — аналогичен событию, но имеет свойства
.done
,.fail
,.pending
и.finally
, с которыми можно взаимодействовать (об этом тоже будет далее).
5) Отсутствие геттеров
Если вы раньше работали с Mobx или Redux, то привыкли, что у модели можно задать геттеры и обращаться к ним для получения, например, отфильтрованных или хитро измененных данных. Как было сказано выше, в Effector такого нет. Но... Зачем нам геттер, если мы можем создать новое хранилище?
Для нас привычно, что хранилище относится к модели 1 к 1. Здесь эта логика рушится в пух и прах. Мы можем создавать несколько хранилищ, связанных друг с другом, как нам нужно.
Пример нового хранилища, зависимого от основного:
// Учебный пример.
// Предположим, на клиенте нужно дополнительное поле со статусом пользователя.
// Оно не приходит с сервера, и мы добавляем его искусственно.
// Добавляем поле Статус каждому пользователю
const serializeUsers = (state: IUser[]) =>
state.map((user) => ({ ...user, onlineStatus: true }));
/**
* Новое хранилище, зависимое от хранилища $users.
* Данные из $users прогоняются через функцию serializeUsers
* и сохраняются в новое хранилище, которое можно использовать в компоненте
*/
export const $usersWithStatus = $users.map(serializeUsers);
6) Отслеживание статуса эффектов
У эффектов есть промисоподобные свойства .done
, .fail
, .pending
и .finally
. Поэтому кажется, что очень удобно отслеживать статус. Но обычно он важен для отображения данных в компоненте: когда мы послали запрос на данные и ожидаем ответа, нужно показывать лоадер; когда данные загружены с ошибкой — нужно показать ошибку. Поэтому необходимо каким-то образом прокидывать эти статусы в компонент. Как было сказано выше, геттеров нет. Но есть хранилища! Можно создать хранилище, сочетающее в себе все статусы:
/* МОДЕЛЬ В EFFECTOR */
// Создаем эффект, который делает GET-запрос на бек
export const getUsersFx = createEffect<void, IUserPayload[], Error>();
// Создаем хранилище, в котором будет лежать ошибка, если GET-запрос зафейлится
// I вариант
export const $fetchError = restore<Error>(getUsersFx.failData, null);
// Создаем другое хранилище, содержащий всю информацию по GET-запросу
export const $usersGetStatus = combine({
loading: getUsersFx.pending,
error: $fetchError,
data: $users,
});
/* КОМПОНЕНТ, ИСПОЛЬЗУЮЩИЙ ХРАНИЛИЩЕ */
export const UserList3 = () => {
// Подключаем хранилище в компонент
const { loading, error, data } = useStore($usersGetStatus);
// Делаем запрос на бек на didMount
useEffect(() => {
getUsersFx();
}, []);
if (loading) {
return (
<div>Загрузка...</div>
);
}
if (error) {
return (
<div>
<span><b>Произошла ошибка: </b></span>
<span>{error.message}</span>
</div>
);
}
const usersItems = data.map((user) => (
<div key={user.id}>
<div>Name: {user.name}</div>
<div>Surname: {user.surname}</div>
<div>Age: {user.age}</div>
<div>Gender: {user.gender}</div>
<br/>
</div>
));
return (
<div>
{usersItems}
</div>
);
};
В приведённом выше варианте создания хранилища $fetchError
был использован еще один метод Effector — restore
. Он позволяет создать хранилище, содержимое которого будет зависеть от события наступления события. Очень удобно использовать для очистки (сброса в начальное состояние) хранилища.
Создать хранилище $fetchError
можно и через стандартный createStore
:
// II вариант
export const $fetchError = createStore<Error | null>(null);
$fetchError
.on(getUsersFx.fail, (_, { error }) => error)
.reset(getUsersFx.done);
Выходим на новый уровень - получаем удовольствие
Несмотря на большое количество непоняток, которые может встретить начинающий эффекторец, в процессе ты понимаешь, что он очень удобный. Ниже я выделила основные пункты, которые понравились лично мне.
1) Никаких лишних телодвижений для подписки на хранилище
При грамотно созданных моделях в компоненте не нужно страдать и отслеживать все свои телодвижения по обновлению хранилища. Подключили его в компонент — он всегда актуален и перерисовывается при каждом обновлении хранилища. Никаких тебе Mobx-овых @action
, @computed
и прочей ручной настройки. Каеф :)
2) Меньше кода (и меньше размер)
Нет надобности создавать отдельные классы-модели, прописывать им интерфейсы. Создали хранилище, создали событие, подписали событие на хранилище — готово!
И да, размер двух подключенных библиотек effector и effector-react составляет около 8 Кб (у Mobx сумма подключенных библиотек — около 15-20 Кб)!
3) Минимальное взаимодействие компонента и хранилища
Когда я решала похожую задачу на Mobx, у меня было очень странное взаимодействие компонента и хранилища:
-
Из компонента посылаем запрос на бек (потому что нужно отслеживать статус запроса).
-
Здесь же получили данные и положили их в хранилище.
Т.е. компонент используется как прокси в этом случае. И это кажется очень странным, потому что зачем? Нам нужно просто положить данные из ответа на запрос в хранилище, без взаимодействия с компонентом.
Effector позволяет реализовать работу напрямую: из хранилища послал запрос, в хранилище положил ответ. И наоборот. Это, например, очень удобно делается с помощью метода forward
. Мы перенаправляем выход эффекта на вход события.
Для примера рассмотрим историю, когда нам нужно обновить хранилище и сразу же отправить запрос на бек. Выше был пример с добавлением искусственного поля onlineStatus
в модель пользователя. Перед отправкой удалим это поле из пейлоада, т.к. бек про него ничего не знает. Описанную историю можно реализовать таким образом:
/* СОЗДАНИЕ СОБЫТИЯ */
// Создаем событие на обновление хранилища
export const update = createEvent<IUser>();
// Хендлер на обновление хранилища (был описан выше)
const updateStore = (state: IUser[], data: IUser) => {
const userIndex = state.findIndex((user) => user.id === data.id);
// Изменяем стейт
if (userIndex > -1) {
state.splice(userIndex, 1, data);
} else {
state.push(data);
}
// Возвращаем измененный стейт
return [...state];
};
// Подписываемся на обновление хранилища через хендлер
$users
.on(update, updateStore)
/**********************************************************/
/* СОЗДАНИЕ ЭФФЕКТА */
// Создаем эффект для изменения данных о пользователе (Запрос на бек)
export const updateUserFx = createEffect<IUserPayload, IUserPayload, Error>();
// Асихронная функция запроса на бек
const updateUser = async (data: IUserPayload): Promise<IUserPayload> => {
const res = await axios({
url: `/users/${data.id}`,
method: 'PATCH',
});
return res.data;
}
// Привязываем к эффекту
updateUserFx.use(updateUser);
/**********************************************************/
/* ПРЕОБРАЗОВАНИЕ ДАННЫХ */
// Изменяем формат данных из хранилища в формат, необходимый для отправки запроса
// (Удаляем искусственное поле onlineStatus)
const serializeDataBeforeFetch = attach<
IUser,
Store<IUser[]>,
typeof updateUserFx
>({
effect: updateUserFx,
source: $users,
mapParams: (params: IUser, data: IUser[]) => {
const user = data.find((item) => item.id === params.id)!;
const userCopy = { ...user };
delete userCopy?.onlineStatus;
return userCopy;
},
});
// Связываем событие и функцию-преобразователь
forward({
from: update,
to: serializeDataBeforeFetch,
});
Пояснения по коду.
Стек вызова упрощенно будет выглядеть следующим образом:
-
update(...)
— вызываем событие на обновление хранилища из компонента. -
updateStore
— хранилище обновляется согласно переданному хендлеру. -
serializeDataBeforeFetch
— после обновления хранилища вызывается функция преобразования его данных в пейлоад. В ней используется метод Effectorattach
, позволяющий сделатьforward
с модификацией. -
updateUserFx
— вызываем эффект на обновление. -
updateUser
— делаем запрос на бек.
Вуаля!
Да, на первый взгляд это выглядит запутанно. Но если в этом разобраться, можно очень удобно использовать «перебрасывание» данных из одной функции в другую.
4) Крутое и отзывчивое сообщество
Когда я поняла, что в документации и гугле нужных мне примеров нет от слова совсем, я решила действовать радикально и пойти в сообщество Effector в Telegram. Я задала один вопрос «от хлебушка», на который я получила за один вечер... 5 разных вариантов решений от разных разработчиков! Причём решения были разные по уровню сложности, я могла выбрать любое из них, или скомбинировать и создать своё. Некоторые решения были очень хорошо расписаны и объяснены, некоторые содержали продуктовый код с примерами прямо на GitHub, некоторые содержали ссылки на воркшопы по Effector. В общем, я приятно удивлена, что есть такое классное сообщество, где ребята всячески поддерживают друг друга :)
Да и в целом в проекте я использовала версию Effector 21.5.0. То есть ребята мажорно обновляли свой проект 20 раз. Это очень существенно!
Best practices
[Для тех, кто хочет знать больше] Об этом есть статья в самой документации, но я кратко продублирую.
-
Названия хранилищ содержат символ
$
. Например,$users
. -
Названия эффектов содержат суффикс
Fx
. Например,getUsersFx
. -
Файловая структура. В корне исходников создается папка
models
, внутри которой лежат все модели, работающие с Effector. У каждой модели есть два файла:-
index.ts
— файл, где мы объявляем все хранилища, события, эффекты. Это файл начального объявления; -
init.ts
— файл, где мы описываем все хранилища, события, эффекты и связываем их между собой. Здесь вся бизнес-логика.
-
Итоги
В заключение хочу заметить, что Effector выглядит наиболее приятным в использовании стейт-менеджером. Он позволяет легко разделять работу с данными по разным хранилищам и не держать всё в одном (декомпозиция лайк). В нем используется неизменяемый стейт и нет необходимости писать много дублирующегося кода, что повышает производительность вашего проекта. Effector обладает удобным API и прекрасным сообществом разработчиков, поддерживающих проект.
Я определенно убеждена, что использование Effector в продуктовой разработке — одно из самых удобных решений. Особенно, если в нем разобраться глубже, чем просто на уровне новичка. Поэтому внедряйте новый стейт-менеджер в свои проекты, пишите комментарии к этой статье и давайте продолжать делать крутой веб вместе ;)
Вместо послесловия
Полезные ссылки:
Автор: Елизавета Добрянская