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

Если раньше была идея - познакомить вас с альтернативой хукам, то сегодня настало время применить "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