АвторМаслов Андрей, 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.

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