Пишу форму без использования хуков

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

Если раньше была идея — познакомить вас с альтернативой хукам, то сегодня настало время применить «hookless» подход к реальному примеру. Для этого я набросал шаблон формы, которую мы будем реализовывать.

b58e9ea966b76238dff2596909d0308e.png

В качестве инструмента для хранения состояния я выбрал effector. Для подключения бизнес-логики к представлению использую библиотеку reflect.

Пример как это может выглядеть

import { reflect } from "@effector/reflect";
import { createEvent, restore } from "effector";
import { Input } from 'antd';
  
const changeSearch = createEvent();
const $search = restore(changeSearch, '');

const SearchInput =  reflect({
    view: Input,
    bind: {
      value: $search,
      onChange: (e) => changeSearch(e.target.value),
    }
})

Я не буду сильно вдаваться в детали и разбирать код на effector, так как делаю акцент на организации кода. В конце статьи я приложу ссылку на репозиторий с реализацией, которую вы сможете подробно изучить.

Наш подход нуждается в разделении нашего кода на логические части, чтобы не писать всё одном файле. Проанализировав форму, я обозначил две группы:

d0b07bf648d46bcc385dcb6b9e6c16ca.png

Интерактивные элементы совмещают в себе две функции отображение и логику. Исходя из этих наблюдений я выделил несколько слоёв:

  • model — содержит бизнес логику

  • ui — включает в себя «глупые» компоненты

  • integration — компоненты обогащённые логикой

  • composition — компоненты, где располагаются integration элементы

4c748f93bb822d1bed24b9e96261227c.png

Цепочка зависимостей выглядит так: ui + model => integration => composition

Предлагаю применить данную структуру на других интерфейсах, например, кнопки лифта.

df366f78010e2fdf24e5136c8c8bd23d.jpg

Каждая кнопка является интерактивным элементом слоя integration. Визуал кнопки относится к ui. Внутренняя логика, которая управляет механизмами — это model слой. А сама стена лифта куда встроен набор кнопок — принадлежит к слою composition.

Другой пример, машина

41da9a5799ec6960aa46fce2b4999f38.png

Корпус машины — это composition слой. Руль, ручки для открывания дверей и всё с чем может взаимодействовать пользователь относятся к слою integration. Логика управляющая механизмами или обрабатывающая информацию с интерактивных элементов — model слой. Визуальная часть машины и её элементов — это ui.

Думаю у вас появятся и другие примеры, c удовольствием прочту их в комментариях.

Вернёмся к web. Начнём с слоя model набросаем нужное количество состояний для наших полей формы.

2d1714a6b91c90bb520c9df65e2a7da2.png

Пример одного из файлов — `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 элементы

6f0e2cfa144095d2a42be1cc7f5e57a0.png

Пример кнопки

import { PropsWithChildren } from "react"

interface Props {
    id?: string;
    onClick?: (e: React.MouseEvent) => void;
    disabled?: boolean;
}

export const Button: React.FC> = ({ id, children, onClick, disabled }) => (
    
)

Добавим компоненты в integration слой

63cf52dbb4d867208215ef3841af1d73.png

На данном этапе хотелось бы показать несколько файлов

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,
    bind: {
        // renderItem: (item) => <>some jsx, 
        data: $workPeriods,
        emptyMessage: "Пустой список периодов",
        extractKey: (item) => item.uuid
    }
})

Для отрисовки элементов списка нам требуется передать функцию renderItem, которая вернёт jsx. У нас для каждого элемента списка будут интерактивные элементы из слоя integration — это input «Название компании», «Кол-во лет» и кнопка «Удалить», а значит мы выносим эту реализацию на уровень слоя композиции.

e34ee755414e05a8fb209eb10fd6bbb7.png

Следующий вопрос, как понять на каком элементе был клик по кнопке удаления? Тут, как говорится , есть два стула…

Первый — можно создать UI компонент, который в себя получает сущность и добавляет в сallback эту сущность.

import { ComponentProps } from "react"
  
import { Button } from "./button"
import { WorkPeriod } from "../model/types";

type ButtonProps = ComponentProps;

interface Props extends Omit {
   item: WorkPeriod;
   onClick?: (event: React.MouseEvent, item: WorkPeriod) => void;
}

export const WorkPeriodButton: React.FC = ({ item, onClick, ...props }) => (

Тогда интеграция получится в таком виде

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

6d58b7e373a59a1d7ce4c3c7d7925718.png

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 }) =>(
    
                           
 ); export const UserForm = () => (    
       
                                     
       
                               
       
            } />                    
       
                               
   
)

Тут лишь располагаем элементы. Нет названия кнопок или настроек, они вынесены в integration компоненты, так как они легко могут стать динамическими. Все интерактивные элементы как на ладони, можем менять их расположение не цепляя при этом логику.

На данный момент у нас получилась такая форма

6d2e75af0273d1947312af6e489a65d4.png

Требуется немного поправить отображение, чтобы сделать нашу форму более привлекательной для пользователя. Подменим часть html элементов на компоненты из библиотеки antd. Получим такой список изменений

8897358ba10e470921554e87c1b6d923.png

Волна изменений не застала model, так как слои ui и model пересекаются только в слое integration. Чёткое разделение границ позволяет изолировать изменения лишь на том участке, где они требуются. Например, если вы добавили баг в ui он с меньшей вероятностью скажется на логике и наоборот.

Это можно сравнить с отсеками на корабле, которые останавливают процесс затопления остальных частей.

fa6341f882c1d23120ac6d788868e49b.png

Конечно, всё зависит от разработчика и его подхода вносить изменения, а я исхожу из идеального случая.

Далее хотелось бы обновить ui из слоя composition

45279fd6f22273bfd3ec551323ac002f.png

Снова можем заметить, что нам удалось ограничиться изменениями только на данном слое. Мы пришли к той точке, которую обозначили в начале.

1a5848edd79ab5d690cf928588c39469.png

По мимо выполненной задачи, мне удалось создать структуру, в которую отлично вписывается «hookless» подход. Её использование не ограничивается только тем подходом, что описываю я. Вы можете заменить hoc в integration слое на «Business Logic Component», где привычным нам способом совместить хук с логикой и ui компонент. Буду очень рад, если вы сможете проверить алгоритм на прочность и рассказать о вашем опыте применения в комментариях.

Помните, все трюки выполнены профессионалами, не пытайтесь повторить их в «боевых» условиях вашего проекта⁠⁠ ;)

Ссылка на репозиторий с разобранным примером — https://github.com/yaroslav-emelyanov/just-form

© Habrahabr.ru