АвторМаслов Андрей, Front-end разработчик.
Время чтения: ~10 минут

Содержание:

  1. О статье

  2. Инструментарий

  3. Демо приложения

  4. effector/reflect

  5. effector-forms

  6. Итоги

О статье

Важно!
Это вторая часть серии статей по менеджеру состояний Effector. Перед ознакомлением с этой статьей настоятельно рекомендую перейти к первой части, лишь затем вернуться к текущей.

Первая часть: Effector — убийца Redux? Туториал с нуля. Часть 1

На примере небольшого приложения с заметками мы рассмотрим основные инструменты при работе с Effector, затронем типизацию.

Инструментарий

Основной упор в этой статье сделан на использование методов из effector/reflect.
Этот инструмент позволит вам внести ясность в ваш код, а так же избавиться от множества рутинных вещей.
Так же затронем работу с формами, с effector-forms.
Используем React :3

Демо приложения

Небольшое показательное приложение с возможностью добавления, удаления, редактирования заметок.

Демо
Демо

GitHub, код можно развернуть и посмотреть по ссылке

@effector/reflect

Рассмотрим основные возможности библиотеки:

  • reflect

  • list

  • variant

reflect

Инициализируем следующую проблему:

import {$notes, deleteNote} from './model'

const NotesList: React.FC<NotesListProps> = () => {
  const styles = useStyles()

  const notes = useStore($notes)
  
  return (
    <div className={styles.container}>
      {notes.map((note, id) => (
        <NoteItem onDelete={() => deleteNote({id})}>{note}</NoteItem>
      ))}
    </div>
  )
}

Заметили ? Нам приходится постоянно тащить за собой в компонент useStore, и чем больше данных вам нужно - тем сильнее разрастается компонент, и это мы еще даже не обрабатываем данные...
Давайте перейдем к первому этапу - воспользуемся reflect.

reflect принимает в себя объект, со следующими свойствами:

  • view (Наш UI)

  • bind (Объект с набором необходимых данных, которые собираемся прокинуть в view)

  • hooks (Хуки обработки при mount, unmount компонента)

Применяем и смотрим на разницу:

import {$notes, deleteNote} from './model'

interface NotesListProps {
 notes: string[]
 deleteNote: ({ id }: {id: number}) => void
}

const NotesListView: React.FC<NotesListProps> = () => {
  const styles = useStyles()

  return (
    <div className={styles.container}>
      {notes.map((note, id) => (
        <NoteItem onDelete={() => deleteNote({id})}>{note}</NoteItem>
      ))}
    </div>
  )
}

export const NotesList = reflect({
  view: NotesListView,
  bind: {
    notes: $notes,
    deleteNote
  }
})

Результат: мы отвязали наш UI компонент от данных, которые ранее были привязаны к компоненту. Кажется, будто бы мы наживаем себе больше проблем, да и к тому же кода стало больше... Но таких NotesList вы можете создать несколько, наследуя базовый view, в зависимости от тех данных, которые вы хотите видеть.

Если вы хотите типизировать reflect и обезопасить себя при байндинге данных, то стоит передать в дженерик первым аргументом ваш интерфейс view

export const NotesList = reflect<NotesListProps>({
  view: NotesListView,
  bind: {
    notes: $notes,
    deleteNote,
    someProp: 1 //TS ERROR!
  }
})

variant

Продолжаем наш "рефакторинг".
variant позволяет нам очень просто обрабатывать состояние наших компонент.
Принимает объект со следующими свойствами:

  • source (Принимает case - состояние компонента, например, $store<string> = 'loading' | 'empty' | 'ready')

  • bind (Принцип как и в reflect)

  • cases (обработчик ваших состояний, принимает объект ключ case - значение component)

  • default (можете обезопасить себя, если вдруг source окажется пустым)

  • hooks (Принцип как и в reflect)

Обработаем кейс, когда заметок нет.

//model.ts
// event на добавление заметки в список
export const addNewNote = createEvent<string>()

// event на удаление заметки из списка
export const deleteNote = createEvent<{id: number}>()

// event на редактирование заметки
export const editNote = createEvent<{id: number, value: string}>()

// store заметок
export const $notes = createStore<string[]>([])
  .on(addNewNote, (store, payload) => ([
    ...store, payload
  ]))
  .on(deleteNote, (store, payload) => (
    store.filter((_note, id) => id !== payload.id)
  ))
  .on(editNote, (store, payload) => (
    store.map((note, id) => {
      if (payload.id === id) return payload.value

      return note
    })
  ))

// store с состоянием стора с заметками 
// (с помощью map мы создаем производный стор, на основе $notes)
export const $notesTypeState = $notes.map(store => {
  if (store.length) {
    return 'data'
  }
  return 'empty'

})

//index.tsx
export const NotesListVariant = variant({
  source: $notesTypeState,
  cases: {
    data: NotesListView,
    empty: () => <Typography variant='h6' color='secondary' style={{ marginLeft: '78px' }}>List is empty</Typography>
  },
  bind: {
    notes: $notes,
    deleteNote
  }
})

Думаю вы заметили вот такую запись createEvent<string>(), дело в том, что event может принимать в себя payloads, вызывая этот event мы обязаны передать ему данные, которые обязательно попадут в стор через .on

.on(addNewNote, (store, payload) => ([
...store, payload
]))

В коде мы по клику на кнопку вытаскиваем значение из target инпута и передаем его в event, который ожидает в аргументах строку, после чего эта строка попадает в стор как новая заметка.

Вернемся к примеру выше, мы создали производный стор, который реагирует на родительский и меняет свое значение, в нашем случае мы создаем два кейса: data, empty (в первом случае - $store имеет длину, а значит имеет заметки, во втором - заметок нет).

В variant мы прокидываем cases: {case1: View1, case2: View2...}, effector автоматически подтянет типы из стора, и применит их к полю cases, ничего лишнего отдать не выйдет, но как и в первом случае вы можете жестко типизировать и контролировать этот момент, необходимо в дженерик прокинуть первым аргументом - интерфейс view (тем самым типизируя bind), вторым - тип стора (тем самым типизируя cases).

export const NotesListVariant = variant<NotesListProps, 'data' | 'empty', {}>()

Результат: мы избавляемся от написания оберток, с обработчиками стора, функциями рендера по условию, от лишних файлов и лишних строчек кода.

list

Казалось, что еще можно сделать ? Ответ: объединить наш reflect и view (где происходит map заметок). В этом нам поможет метод list.
List принимает объект, с чуть большим кол-вом свойств:

  • source (Отсюда черпаем данные, Store<any>)

  • view (Наш UI)

  • mapItem (Имитируем map + bind)

  • bind (Прокидываем дополнительные данные, которых нет в функции map)

  • hooks (Аналогично остальным методам)

  • getKey (Ключи для оптимизации)

В итоге наша картина складывается очень гармонично.

Было:

const NotesList: React.FC<NotesListProps> = () => {
  const notes = useStore($notes)

  if (!notes.length) {
    return <Typography variant='h6' color='secondary' style={{ marginLeft: '78px' }}>List is empty</Typography>
  }

  //if (some condition1...) {}
  //if (some condition2...) {}
  //if (some condition3...) {}

  return (
   <div>
     {notes.map((note, id) => (
            <NoteItem 
              value={note}
              onDelete={() => deleteNote({ id })} 
              onSave={(value) => editNote({ id, value }}
              key={id}
            />
        ))}
   </div>
  )
 }

Стало:

const NotesListView = list({
  view: NoteItem,
  source: $notes,
  mapItem: {
    value: (note) => note,
    onDelete: (_, id) => () => deleteNote({ id }),
    onSave: (_, id) => (value) => editNote({ id, value })
  },
  getKey: () => React.Key
})

export const NotesListVariant = variant({
  source: $notesTypeState,
  cases: {
    data: NotesListView,
    empty: () => <Typography variant='h6' color='secondary' style={{ marginLeft: '78px' }}>List is empty</Typography>
  }
})

Вуаля! Думаю результат на все сто. Из variant у нас ушли привязка пропов через bind - теперь этим занимается list.

Вы можете написать обертку, которая будет принимать начальный стор и генерировать производный с состоянием компонента, например, для самых популярных решений: loading, error, ready, empty. И работа упростится в разы (Нужды каждый раз писать кейсы, создавать сторы с типами кейсов и тд не будет).

effector-forms

Остановимся буквально на пару слов, для начала работы с формами вам стоит знать лишь о существовании effector-forms.

Пример ниже взят из документации, ибо его более чем достаточно, вопросов возникать не должно, мотивация - избавиться от создания сторов на каждое поле (в нашем приложении все завязано на сторе, т.к поле одно).

В материалах оставлю ссылку, где каждый кейс подробно разобран, прочитав, вы сразу же сможете пользоваться effector-forms.

import { createEffect } from "effector"
import { createForm } from 'effector-forms'

//инициализация формы
export const loginForm = createForm({
    fields: {
        //добавление филдов
        email: {
            init: "", //дефолтное значение
            rules: [ //в валидатор прокидывайте ваши yup схемы и наслаждайтесь
                {
                    name: "email",
                    validator: (value: string) => /\S+@\S+\.\S+/.test(value)
                },
            ],
        },
        password: {
            init: "", // field's store initial value
            rules: [
              {
                name: "required",
                validator: (value: string) => Boolean(value),
              }
            ],
        },
    },
    validateOn: ["submit"],//тип валидации (submit, change..., аналогично react-hook-form)
})

Итоги

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

В следующей и заключительной статье мы поговорим об архитектуре (так как текущая реализация собрана на коленке для наглядности функционала), и доработаем наше маленькое приложение.

Материалы для закрепления

Комментарии (12)


  1. Alexandroppolus
    23.11.2022 17:58
    +4

    list ... Было/Стало

    Спорное улучшение. Вместо отчетливо видимой "UI как функции от стейта", получили какой-то конфиг, без существенной экономии кода. И это в самом базовом варианте.


    1. amas1ov Автор
      23.11.2022 18:30
      +1

      Вместо отчетливо видимой

      Думаю не стоит путать "отчетливо видимой" и "привычной", иногда не все привычное есть хорошо, но здесь дело вкуса и опыта, соглашусь, что для кого-то это может оказаться спорным моментом.

      без существенной экономии кода. И это в самом базовом варианте.

      В том то и дело, что в базом варианте экономии и не видно (для меня это не только про экономие, но и про удобство - это из рязряда нужно брать и пробовать), чем больше компонент, чем больше у него состояний, тем больше наша "экономия", + в статье писал про обертку

      export const statusesList = ({ effect, source }: Store<ListState>) => {
        return combine(status({effect}), source, (effectStatus, list) =>
          effectStatus === 'done'
            ? Object.values(list).length
              ? 'done'
              : 'empty'
            : effectStatus,
        );
      }
      ....
      
      export const $someStoreStatus = statusesList({
        effect: someEffect,
        source: $someStore,
      });

      Здесь мы можем обработать базовые исходы все, и теперь, когда в "Было" мы будем каждый компонент обмусоливать, в "Стало" вызовем list и передадим базовые кейсы, вызвав statusesList. (к слову, подобное можно реализовать в любом инструменте, дело в поддержке всего этого написанного)




      1. funca
        24.11.2022 00:27
        +3

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

        Давайте перейдем к первому этапу - воспользуемся reflect.

        Реакт постоянно тянет вывернуться на изнанку и обернуться в мир фантазий с элементами функционального программирования. Но его все равно оттуда достают. Помните HOC, mapStateToProps, а потом нет - хуки?

        Effector крутой фреймворк, но то, о чем вы рассказываете в данной публикации - спорно.

        В реакте принято выражать композицию компонентов с помощью вложенности тегов друг в друга. Да, все то же самое можно делать и другими способами - чисто технически это может быть даже проще, - но это противоречит главному принципу.

        Композируя компоненты не через другие компоненты, а, допустим, через данные, вы делаете рендеринг в целом зависимым от каких-то сторонних, ни как не контролируемых реактом факторов. Это довольно быстро начинает выходить боком в поддержке. Начиная с невнятных стектрейсов и вплоть до трудно решаемых проблем с перфомансом из-за того что компоненты прорастают из данных когда угодно.


  1. nin-jin
    24.11.2022 11:45

    Отрефакторил ваше приложение используя реактивный JSX: SLOC уменьшился примерно в 2 раза, а объём зависимостей в сжатом виде в 15 раз.


    1. konclave
      24.11.2022 16:30
      +1

      Отрефакторил ваше приложение используя реактивный JSX:

      А зачем?


      1. amas1ov Автор
        24.11.2022 17:57
        +2

        Человек, удалив зависимости, в виде материал ui и других, почистив код и приведя его к низкому уровню, пытается выставить mol (оперируя фактами кол-ва строк кода и объема зивисимостей), зачем - вопрос. Хотя статья - туториал, и никаких сравнений здесь не должно быть)


        1. nin-jin
          24.11.2022 19:02

          приведя его к низкому уровню

          Наоборот, к высокому. Вьюшки и модели представлены самодостаточными классами, а не лапшой из глобальных переменных. Не стоит благодарности.


        1. funca
          25.11.2022 22:45

          На самом деле интересно, хоть и неожиданно. Любую задачу можно решить разными способами и разница в виде diff выглядит наглядно.

          Для полноты картины не хватает модульных тестов и какого-нибудь сторибука. Они хорошо, как лакмусовая бумажка, показывают из каких соображений и насколько хорошо тот или иной подход позволил декомпозировать код (или же получилась красиво приготовленная лапша, где все зависит от всего).


          1. nin-jin
            26.11.2022 08:30
            +1

            Добавил туда и тесты.


            1. funca
              26.11.2022 10:24

              Чистенько :) Только один вопрос. В приложении компоненты используется декларативно:

              <NotesList
                id='notes'
                notes={ ()=> this.notes() }
              />

              а в тесте создаются императивно и без таких вот пропертей:

              const view = new NotesList
              
              view.notes().make( 'foo' )
              view.notes().make( 'bar' )
              
              const dom = view.render()

              Уверен, что связь там наверняка тривиальная. Но все равно не покидает ощущение, что тест тестит что-то другое. Можете добавить пример теста с jsx (или объяснить почему так делать не стоит если у вас другое мнение)?


              1. nin-jin
                26.11.2022 11:21

                JSX тут на выходе даёт настоящий DOM, а не виртуальный. Из него, конечно, можно получить экземпляр компонента, но зачем, если его можно сразу и создать. К тому же рендеринг зачастую и не нужен - достаточно дёргать апи компонента.

                Ну а к декларативности JSX не имеет никакого отношения.


      1. nin-jin
        24.11.2022 19:03
        -2