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

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

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

cda5f797af26110630b8d38249356006.png

Содержание:

  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
export const update = createEvent();

// Обычный хендлер на обновление. Добавляем или изменяем пользователя
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) => (
    
Name: {user.name}
Surname: {user.surname}
Age: {user.age}
Gender: {user.gender}

)); return (
{usersItems}
); };

Ради интереса можно попробовать сделать то же самое, но с хуком useList. Он предоставляет упрощенный вариант взаимодействия с хранилищем-массивом. Реализация аналогичной задачи:

import { useList } from 'effector-react';
import { $users, update } from 'models/users';

export const UserList2 = () => {
    // Можно преобразовать в массив нод сразу при подключении.
    // Не нужно использовать пропс key, как было с map()
  const users = useList($users, (user) => (
    
Name: {user.name}
Surname: {user.surname}
Age: {user.age}
Gender: {user.gender}

)); const mockUser = { id: 2222, name: 'Diana', surname: 'Gregory', age: 22, gender: 'female', }; return (
{users}
); };

Об этом и многом другом можно почитать в официальной документации Effector. Поэтому долго не будем на этом останавливаться и перейдем к «самому сладенькому». Далее я расскажу про боли и страдания в процессе работы с этим, казалось бы, очень простым и удобным стейт-менеджером. Без купюр.

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

Не стоит думать, что статья про несовершенный, наполненный кучей проблем и багов продукт увидела бы свет. Проблемы, описанные здесь, я встретила будучи полным новичком в Effector. По итогу они были повержены, а автор этой статьи — счастлив :) Если вы встретите похожие проблемы, то можете воспользоваться приведенными решениями или модернизировать их, чтобы создать своё.

1) TypeScript

Да, самым сложным для меня оказалась поддержка такого же модного и молодежного, как и Effector, языка программирования TypeScript. В официальной документации Effector-а все примеры приведены на чистом JavaScript. Есть, конечно, маленькая робкая вкладка «TypeScript», которая, в основном, даёт только понимание того, куда нужно добавить типы в описании основных сущностей, но на этом всё. Поэтому сначала я использовала any, а под конец пришлось очень много страдать с расстановкой правильных типов (особенно касательно эффектов).

fc118ebd96cc60a2d3535c0021a2c329.png

Так, например, родились следующие интерфейсы функций (слабонервным не смотреть):

// Создаем эффекты для получения и изменения данных о пользователях
// IUserPayload - интерфейс пользователя, приходящий с сервера
export const getUsersFx = createEffect();
export const updateUserFx = createEffect<
	IUserPayload, 
	IUserPayload, 
	Error
>();

// Изменяем формат данных из хранилища в формат, необходимый для отправки запроса
const serializeDataBeforeFetch = attach<
  IUser,
  Store,
  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();

// Создаем хранилище, в котором будет лежать ошибка, если GET-запрос зафейлится
// I вариант
export const $fetchError = restore(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 (
      
Загрузка...
); } if (error) { return (
Произошла ошибка: {error.message}
); } const usersItems = data.map((user) => (
Name: {user.name}
Surname: {user.surname}
Age: {user.age}
Gender: {user.gender}

)); return (
{usersItems}
); };

В приведённом выше варианте создания хранилища $fetchError был использован еще один метод Effector — restore. Он позволяет создать хранилище, содержимое которого будет зависеть от события наступления события. Очень удобно использовать для очистки (сброса в начальное состояние) хранилища.

Создать хранилище $fetchError можно и через стандартный createStore:

// II вариант
export const $fetchError = createStore(null);
$fetchError
  .on(getUsersFx.fail, (_, { error }) => error)
  .reset(getUsersFx.done);

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

Несмотря на большое количество непоняток, которые может встретить начинающий эффекторец, в процессе ты понимаешь, что он очень удобный. Ниже я выделила основные пункты, которые понравились лично мне.

1) Никаких лишних телодвижений для подписки на хранилище

При грамотно созданных моделях в компоненте не нужно страдать и отслеживать все свои телодвижения по обновлению хранилища. Подключили его в компонент — он всегда актуален и перерисовывается при каждом обновлении хранилища. Никаких тебе Mobx-овых @action, @computed и прочей ручной настройки. Каеф :)

2) Меньше кода (и меньше размер)

Нет надобности создавать отдельные классы-модели, прописывать им интерфейсы. Создали хранилище, создали событие, подписали событие на хранилище — готово!

И да, размер двух подключенных библиотек effector и effector-react составляет около 8 Кб (у Mobx сумма подключенных библиотек — около 15–20 Кб)!

98e373ba6a6f9ce8384af191b4d5a9f4.png

3) Минимальное взаимодействие компонента и хранилища

Когда я решала похожую задачу на Mobx, у меня было очень странное взаимодействие компонента и хранилища:

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

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

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

Effector позволяет реализовать работу напрямую: из хранилища послал запрос, в хранилище положил ответ. И наоборот. Это, например, очень удобно делается с помощью метода forward. Мы перенаправляем выход эффекта на вход события.

Для примера рассмотрим историю, когда нам нужно обновить хранилище и сразу же отправить запрос на бек. Выше был пример с добавлением искусственного поля onlineStatus в модель пользователя. Перед отправкой удалим это поле из пейлоада, т.к. бек про него ничего не знает. Описанную историю можно реализовать таким образом:

/* СОЗДАНИЕ СОБЫТИЯ */

// Создаем событие на обновление хранилища
export const update = createEvent();

// Хендлер на обновление хранилища (был описан выше)
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();

// Асихронная функция запроса на бек
const updateUser = async (data: IUserPayload): Promise => {
  const res = await axios({
    url: `/users/${data.id}`,
    method: 'PATCH',
  });
  return res.data;
}

// Привязываем к эффекту
updateUserFx.use(updateUser);

/**********************************************************/

/* ПРЕОБРАЗОВАНИЕ ДАННЫХ */

// Изменяем формат данных из хранилища в формат, необходимый для отправки запроса
// (Удаляем искусственное поле onlineStatus)
const serializeDataBeforeFetch = attach<
  IUser,
  Store,
  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 — делаем запрос на бек.

Вуаля!

b8c4ffe08a861ee855b1605de1832b2a.png

Да, на первый взгляд это выглядит запутанно. Но если в этом разобраться, можно очень удобно использовать «перебрасывание» данных из одной функции в другую.

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

22918bb321f9cbad44959c9afd7778f9.png

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

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

© Habrahabr.ru