Автор: Маслов Андрей, 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.
В следующей и заключительной статье мы поговорим об архитектуре (так как текущая реализация собрана на коленке для наглядности функционала), и доработаем наше маленькое приложение.
Материалы для закрепления
Комментарии (12)
nin-jin
24.11.2022 11:45Отрефакторил ваше приложение используя реактивный JSX: SLOC уменьшился примерно в 2 раза, а объём зависимостей в сжатом виде в 15 раз.
konclave
24.11.2022 16:30+1Отрефакторил ваше приложение используя реактивный JSX:
А зачем?
amas1ov Автор
24.11.2022 17:57+2Человек, удалив зависимости, в виде материал ui и других, почистив код и приведя его к низкому уровню, пытается выставить mol (оперируя фактами кол-ва строк кода и объема зивисимостей), зачем - вопрос. Хотя статья - туториал, и никаких сравнений здесь не должно быть)
nin-jin
24.11.2022 19:02приведя его к низкому уровню
Наоборот, к высокому. Вьюшки и модели представлены самодостаточными классами, а не лапшой из глобальных переменных. Не стоит благодарности.
funca
25.11.2022 22:45На самом деле интересно, хоть и неожиданно. Любую задачу можно решить разными способами и разница в виде diff выглядит наглядно.
Для полноты картины не хватает модульных тестов и какого-нибудь сторибука. Они хорошо, как лакмусовая бумажка, показывают из каких соображений и насколько хорошо тот или иной подход позволил декомпозировать код (или же получилась красиво приготовленная лапша, где все зависит от всего).
nin-jin
26.11.2022 08:30+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 (или объяснить почему так делать не стоит если у вас другое мнение)?
nin-jin
26.11.2022 11:21JSX тут на выходе даёт настоящий DOM, а не виртуальный. Из него, конечно, можно получить экземпляр компонента, но зачем, если его можно сразу и создать. К тому же рендеринг зачастую и не нужен - достаточно дёргать апи компонента.
Ну а к декларативности JSX не имеет никакого отношения.
Alexandroppolus
Спорное улучшение. Вместо отчетливо видимой "UI как функции от стейта", получили какой-то конфиг, без существенной экономии кода. И это в самом базовом варианте.
amas1ov Автор
Думаю не стоит путать "отчетливо видимой" и "привычной", иногда не все привычное есть хорошо, но здесь дело вкуса и опыта, соглашусь, что для кого-то это может оказаться спорным моментом.
В том то и дело, что в базом варианте экономии и не видно (для меня это не только про экономие, но и про удобство - это из рязряда нужно брать и пробовать), чем больше компонент, чем больше у него состояний, тем больше наша "экономия", + в статье писал про обертку
Здесь мы можем обработать базовые исходы все, и теперь, когда в "Было" мы будем каждый компонент обмусоливать, в "Стало" вызовем list и передадим базовые кейсы, вызвав statusesList. (к слову, подобное можно реализовать в любом инструменте, дело в поддержке всего этого написанного)
funca
Реакт постоянно тянет вывернуться на изнанку и обернуться в мир фантазий с элементами функционального программирования. Но его все равно оттуда достают. Помните HOC, mapStateToProps, а потом нет - хуки?
Effector крутой фреймворк, но то, о чем вы рассказываете в данной публикации - спорно.
В реакте принято выражать композицию компонентов с помощью вложенности тегов друг в друга. Да, все то же самое можно делать и другими способами - чисто технически это может быть даже проще, - но это противоречит главному принципу.
Композируя компоненты не через другие компоненты, а, допустим, через данные, вы делаете рендеринг в целом зависимым от каких-то сторонних, ни как не контролируемых реактом факторов. Это довольно быстро начинает выходить боком в поддержке. Начиная с невнятных стектрейсов и вплоть до трудно решаемых проблем с перфомансом из-за того что компоненты прорастают из данных когда угодно.