Effector — стейтменеджер js приложений (reflect, typescript, forms). Работа с основными инструментами. Часть 2
Автор: Маслов Андрей, Front-end разработчик.
Время чтения: ~10 минут
Содержание:
О статье
Инструментарий
Демо приложения
effector/reflect
effector-forms
Итоги
О статье
Важно!
Это вторая часть серии статей по менеджеру состояний 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.