Использование Effector в стеке React + TypeScript

от автора

Всем привет! Меня зовут Елизавета Добрянская, я frontend-разработчик в компании ДомКлик. Моя команда занимается разработкой сервисов, предназначенных для коммуникаций с клиентом.

В этой статье я поделюсь своим кратким обзором внедрения стейт-менеджера Effector в продуктовый проект на стеке React + TypeScript, а также покажу на примере, как легко это можно сделать.

Содержание:

  1. Немного предыстории

  2. Первая встреча с Effector

  3. Боль как начало

  4. Выходим на новый уровень — получаем удовольствие

  5. Best practices

  6. Итоги

  7. Вместо послесловия

Немного предыстории

Моя команда занимается разработкой разных видов сервисов коммуникаций — отдельных виджетов, 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;   }, }); 

Небольшие пояснения по коду.

Эффекты имеют следующий формат типизации:

  1. Тип передаваемого в эффект значения.

  2. Тип возвращаемого из эффекта значения.

  3. Тип ошибки для случая, если что-то пошло не так.

Про функцию serializeDataBeforeFetch расскажу ниже, а пока стоит обратить внимание на типы метода attach, предоставляемого Effector:

  1. Тип передаваемого значения.

  2. Тип данных хранилища.

  3. Тип эффекта, используемого внутри 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, у меня было очень странное взаимодействие компонента и хранилища:

  1. Из компонента посылаем запрос на бек (потому что нужно отслеживать статус запроса).

  2. Здесь же получили данные и положили их в хранилище.

Т.е. компонент используется как прокси в этом случае. И это кажется очень странным, потому что зачем? Нам нужно просто положить данные из ответа на запрос в хранилище, без взаимодействия с компонентом.

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, }); 

Пояснения по коду.

Стек вызова упрощенно будет выглядеть следующим образом:

  1. update(...) — вызываем событие на обновление хранилища из компонента.

  2. updateStore — хранилище обновляется согласно переданному хендлеру.

  3. serializeDataBeforeFetch — после обновления хранилища вызывается функция преобразования его данных в пейлоад. В ней используется метод Effector attach, позволяющий сделать forward с модификацией.

  4. updateUserFx — вызываем эффект на обновление.

  5. 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 в продуктовой разработке — одно из самых удобных решений. Особенно, если в нем разобраться глубже, чем просто на уровне новичка. Поэтому внедряйте новый стейт-менеджер в свои проекты, пишите комментарии к этой статье и давайте продолжать делать крутой веб вместе 😉

Вместо послесловия

Полезные ссылки:

ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/532016/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *