При создании форм на React встает вопрос управления состоянием приложения. Казалось бы, богатый выбор, однако Redux поражает своим "fizzbuzz enterprise", а Mobx сайд-эффектами в ООП коде от начинающих разработчиков. Исходя из моего опыта, оба инструмента плохо подходят для онбординга начинающих программистов. А бизнесу их трудоустроить очень выгодно: они дешевые, пугливые и наивные

Решением проблемы будет выкидывание лишних абстракций из кодовой базы Frontend. Бизнес-логика, размазанная на несколько файлов, вгоняет начинающего разработчика в ступор. Я хотел бы поделиться одной хорошо зарекомендовавшей себя практикой.

Давным-давно, в далёкой-далёкой галактике…

На момент 2010-ого года, когда управление состоянием веб-приложения переходило от ненаправленного потока данных (jQuery) к контейнерам состояния, первые фреймворки широко использовали разновидности паттерна MVVM (AngularJS, MarionetteJS, BackboneJS). Дело в том, что на тот момент Microsoft пытались захватить рынок и широко продвигали Silverlight, разновидность WPF для интернета. И бой был на смерть, к примеру, язык разметки XAML первым ввел нечто похожее на FlexBox и CSS Grid (см. StackPanel и Grid). Веб копировал наработки мелкомягких по максимуму.

Ошибка, которая привела к смерти Backbone

Подход Backbone к управлению состоянием приложения подразумевал использование сущности Collection, которая автоматически синхронизирует содержимое массива с CRUD на стороне backend.

Изменение объекта, который лежит внутри Collection, порождало перерисовку приложения. Идея хорошая, однако:

  1. Прикладной программист должен наследовать свой класс от Collection, переопределив внутри методы для обращения к backend. Наследование, как порождение лишней абстракции, антипаттерн для frontend, нужно использовать композицию.

  2. Collection предоставлял более 10 методов к переопределению для синхронизации содержимого с backend. Как правило, переопределяли только одну функцию syncCollection

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

Что можно сделать?

Можно оставить на Collection только задачу перерисовки UI, вынеся запросы к backend на чтение и запись в обработчики действий пользователя до мутации данных

import { useCollection } from "react-declarative";

const ListItem = ({ entity }) => {

  const handleIncrement = () => {
    /*
    await fetch(`/api/v1/counters/${entity.id}`, {
      method: "PATCH",
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        counter: entity.data.counter + 1
      })
    });
    */
    entity.setData({
      id: entity.id,
      counter: entity.data.counter + 1
    });
  };

  return (
    <div key={entity.id}>
      {entity.data.counter}
      <button onClick={handleIncrement}>Increment counter</button>
    </div>
  );
};

export const App = () => {
  const collection = useCollection({
    onChange: (collection, target) =>
      console.log({
        collection,
        target
      }),
    initialValue: [] // await fetch() or props...
  });

  const handleAdd = () => {
    /*
    const { id, ...data } = await fetch("/api/v1/counters", {
      method: "POST",
    }).then((data) => data.json());
    */
    collection.push({
      id: Math.max(...collection.ids, 0) + 1,
      counter: 0
      // ...data
    });
  };

  return (
    <>
      {collection.map((entity) => (
        <ListItem key={entity.id} entity={entity} />
      ))}
      <button onClick={handleAdd}>Add item</button>
    </>
  );
};

export default App;

Код, представленный выше, выводит список счетчиков (демо на codesandbox). Внутри кода закомментированы места, где можно обратиться к серверу. Также возможно обработать исключение в запросе так, что блок catch будет выполнен в контексте формы. Например, это можно использовать, чтобы вывести snackbar из хука notistack

import { useSnackbar } from 'notistack';

...

const { enqueueSnackbar } = useSnackbar();

const handleNetworkRequest = () => {
  fetchSomeData()
    .then(() => enqueueSnackbar('Successfully fetched the data.'))
    .catch(() => enqueueSnackbar('Failed fetching data.'));

Массив, содержимое которого требуется синхронизировать с backend, нужно положить в аргумент initialValue хука useCollection (строка 38). Хук вернет объект Collection, реализующий метод map, позволяющий бесшовно с массивом вывести список элементов. Каждый элемент массива будет обернут в контейнер Entity, предоставляющий доступ к оригинальному значению через свойство data и метод setData. Вызов метода setData (строка 17) синхронно изменит data и, через debounce, попросит хук useCollection перерисовать форму

Как синхронизировать один объект?

По аналогии с useCollection, экспортируется хук useEntity. Он вернет переданный в аргументы объект, обернутый в Entity, вызов setData у последнего также перерисует форму.

Используя два вышеупомянутых хука можно сэкономить на передаче обратных вызовов через props, убрать лишний boilerplate, не терять контекст исполнения при разбиении взаимосвязанной бизнес-логики на разные файлы

Где посмотреть код хуков?

Чтобы убрать нужду плодить зависимости в вашем проекте, предоставляю ссылки на файлы)

  1. Collection

  2. Entity

  3. useCollection

  4. useEntity

Комментарии (0)