Приветствую, некоторое время назад я рассказывал о своём небольшом исследовании по разделению представления и бизнес-логики. Это исследование сформировало у меня ощущение, что хуки - не совсем органичная часть компонента. Если вы не понимаете о чём я, рекомендую прочитать мою предыдущую статью, прежде чем приступить к этой.
Если раньше была идея - познакомить вас с альтернативой хукам, то сегодня настало время применить "hookless" подход к реальному примеру. Для этого я набросал шаблон формы, которую мы будем реализовывать.
В качестве инструмента для хранения состояния я выбрал effector
. Для подключения бизнес-логики к представлению использую библиотеку reflect
.
Пример как это может выглядеть
import { reflect } from "@effector/reflect";
import { createEvent, restore } from "effector";
import { Input } from 'antd';
const changeSearch = createEvent<string>();
const $search = restore(changeSearch, '');
const SearchInput = reflect({
view: Input,
bind: {
value: $search,
onChange: (e) => changeSearch(e.target.value),
}
})
Я не буду сильно вдаваться в детали и разбирать код на effector
, так как делаю акцент на организации кода. В конце статьи я приложу ссылку на репозиторий с реализацией, которую вы сможете подробно изучить.
Наш подход нуждается в разделении нашего кода на логические части, чтобы не писать всё одном файле. Проанализировав форму, я обозначил две группы:
Интерактивные элементы совмещают в себе две функции отображение и логику. Исходя из этих наблюдений я выделил несколько слоёв:
model - содержит бизнес логику
ui - включает в себя "глупые" компоненты
integration - компоненты обогащённые логикой
composition - компоненты, где располагаются integration элементы
Цепочка зависимостей выглядит так: ui + model => integration => composition
Предлагаю применить данную структуру на других интерфейсах, например, кнопки лифта.
Каждая кнопка является интерактивным элементом слоя integration
. Визуал кнопки относится к ui
. Внутренняя логика, которая управляет механизмами - это model
слой. А сама стена лифта куда встроен набор кнопок - принадлежит к слою composition
.
Другой пример, машина
Корпус машины - это composition
слой. Руль, ручки для открывания дверей и всё с чем может взаимодействовать пользователь относятся к слою integration
. Логика управляющая механизмами или обрабатывающая информацию с интерактивных элементов - model
слой. Визуальная часть машины и её элементов - это ui
.
Думаю у вас появятся и другие примеры, c удовольствием прочту их в комментариях.
Вернёмся к web. Начнём с слоя model набросаем нужное количество состояний для наших полей формы.
Пример одного из файлов - `user-form.ts`, который объединяет все состояния для отправки
import { createEvent, sample } from 'effector';
import { $workPeriods } from './work-periods';
import { $firstName, $lastName } from './name'; // забыл достать $middleName
import { $yearsOld } from './years-old';
import { $jobPosition } from './job-position';
export const submit = createEvent();
const validSubmit = sample({
clock: submit,
source: {
firstName: $firstName,
lastName: $lastName,
yearsOld: $yearsOld,
jobPosition: $jobPosition,
workPeriods: $workPeriods
}
})
validSubmit.watch((data) => console.log('form', data)) // => { firstName: 'Иван', lastName: 'Иванов', yearsOld: 33, jobPosition: 'doctor', workPeriods: [] }
Добавим ui элементы
Пример кнопки
import { PropsWithChildren } from "react"
interface Props {
id?: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
}
export const Button: React.FC<PropsWithChildren<Props>> = ({ id, children, onClick, disabled }) => (
<button id={id} onClick={onClick} disabled={disabled}>{children}</button>
)
Добавим компоненты в integration слой
На данном этапе хотелось бы показать несколько файлов
import { reflect } from "@effector/reflect";
import { Input } from "../ui/input";
import { $firstName, changeFirstName } from "../model/name";
export const FirstNameInput = reflect({
view: Input,
bind: {
label: "Имя",
name: "name",
value: $firstName,
onChange: (e) => changeFirstName(e.target.value)
}
})
По импортам видно что мы пытаемся соединить в этом файле ui и model.
Если с такими компонентами всё достаточно просто. Как работать со списком? Рассмотрим интеграцию списка
import { reflect } from "@effector/reflect";
import { List } from "../ui/list";
import { $workPeriods } from "../model/work-periods";
import { WorkPeriod } from "../model/types";
export const WorkPeriodList = reflect({
view: List<WorkPeriod>,
bind: {
// renderItem: (item) => <>some jsx</>,
data: $workPeriods,
emptyMessage: "Пустой список периодов",
extractKey: (item) => item.uuid
}
})
Для отрисовки элементов списка нам требуется передать функцию renderItem
, которая вернёт jsx. У нас для каждого элемента списка будут интерактивные элементы из слоя integration
- это input "Название компании", "Кол-во лет" и кнопка "Удалить", а значит мы выносим эту реализацию на уровень слоя композиции.
Следующий вопрос, как понять на каком элементе был клик по кнопке удаления? Тут, как говорится , есть два стула...
Первый - можно создать UI компонент, который в себя получает сущность и добавляет в сallback эту сущность.
import { ComponentProps } from "react"
import { Button } from "./button"
import { WorkPeriod } from "../model/types";
type ButtonProps = ComponentProps<typeof Button>;
interface Props extends Omit<ButtonProps, 'onClick'> {
item: WorkPeriod;
onClick?: (event: React.MouseEvent<HTMLElement>, item: WorkPeriod) => void;
}
export const WorkPeriodButton: React.FC<Props> = ({ item, onClick, ...props }) => (<Button {...props} onClick={(e) => onClick?.(e, item)} />)
Тогда интеграция получится в таком виде
import { reflect } from "@effector/reflect";
import { WorkPeriodButton } from "../ui/work-period-button";
import { removeWorkPeriod } from "../model/work-periods";
export const WorkPeriodDeleteButton = reflect({
view: WorkPeriodButton,
bind: {
children: 'Удалить',
onClick: (_, item) => removeWorkPeriod(item.uuid)
}
});
Второй вариант, на котором остановился я - получение id сущности из атрибута html элемента, который надо передать в него, чтобы не создавать новый компонент для бизнес сущности.
import { reflect } from "@effector/reflect";
import { Button } from "../ui/button";
import { removeWorkPeriod } from "../model/work-periods";
export const WorkPeriodDeleteButton = reflect({
view: Button,
bind: {
children: 'Удалить',
onClick: (e) => removeWorkPeriod(e.currentTarget.id)
}
});
Создаём композицию и выносим наш виджет в app.tsx
import { AddWorkPeriodButton } from "../integration/add-work-period";
import { WorkPeriodCompanyNameInput } from "../integration/company-name-input";
import { WorkPeriodDeleteButton } from "../integration/delete-work-period";
import { FirstNameInput } from "../integration/first-name-input";
import { JobPositionSelect } from "../integration/job-position-select";
import { LastNameInput } from "../integration/last-name-input";
import { MiddleNameInput } from "../integration/middle-name-input";
import { SubmitButton } from "../integration/submit-button";
import { WorkPeriodList } from "../integration/work-period-list";
import { WorkPeriodNumberYearsInput } from "../integration/work-period-input";
import { SumYearsText } from "../integration/sum-years-text";
import { YearsOldInput } from "../integration/user-years-old-input";
import { WorkPeriod } from "../model/types";
const WorkPeriod: React.FC<{ item: WorkPeriod }> = ({ item }) =>(
<div style={{ width: '100%', display: "flex", gap: 8, alignItems: 'flex-end' }}>
<WorkPeriodCompanyNameInput id={item.uuid} value={item.companyName} />
<WorkPeriodNumberYearsInput id={item.uuid} value={item.numberYears} />
<WorkPeriodDeleteButton id={item.uuid} />
</div>
);
export const UserForm = () => (
<div style={{ width: 600, display: 'flex', flexDirection: 'column', gap: 8, padding: 8, border: "1px solid black" }}>
<div style={{ display: 'flex', gap: 8 }}>
<FirstNameInput />
<LastNameInput />
<MiddleNameInput />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<YearsOldInput />
<JobPositionSelect />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: 8, border: "1px solid black" }}>
<WorkPeriodList renderItem={(item) => <WorkPeriod item={item} />} />
<AddWorkPeriodButton />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<SumYearsText />
<SubmitButton />
</div>
</div>
)
Тут лишь располагаем элементы. Нет названия кнопок или настроек, они вынесены в integration
компоненты, так как они легко могут стать динамическими. Все интерактивные элементы как на ладони, можем менять их расположение не цепляя при этом логику.
На данный момент у нас получилась такая форма
Требуется немного поправить отображение, чтобы сделать нашу форму более привлекательной для пользователя. Подменим часть html элементов на компоненты из библиотеки antd
. Получим такой список изменений
Волна изменений не застала model, так как слои ui и model пересекаются только в слое integration
. Чёткое разделение границ позволяет изолировать изменения лишь на том участке, где они требуются. Например, если вы добавили баг в ui он с меньшей вероятностью скажется на логике и наоборот.
Это можно сравнить с отсеками на корабле, которые останавливают процесс затопления остальных частей.
Конечно, всё зависит от разработчика и его подхода вносить изменения, а я исхожу из идеального случая.
Далее хотелось бы обновить ui из слоя composition
Снова можем заметить, что нам удалось ограничиться изменениями только на данном слое. Мы пришли к той точке, которую обозначили в начале.
По мимо выполненной задачи, мне удалось создать структуру, в которую отлично вписывается "hookless" подход. Её использование не ограничивается только тем подходом, что описываю я. Вы можете заменить hoc в integration
слое на "Business Logic Component", где привычным нам способом совместить хук с логикой и ui компонент. Буду очень рад, если вы сможете проверить алгоритм на прочность и рассказать о вашем опыте применения в комментариях.
Помните, все трюки выполнены профессионалами, не пытайтесь повторить их в "боевых" условиях вашего проекта ;)
Ссылка на репозиторий с разобранным примером - https://github.com/yaroslav-emelyanov/just-form