Хватит писать логику в компонентах

Kelin2025

Anton Kosykh

11/1/2021 · Сколько-то там минут

Что было раньше?

Когда-то давным давно весь стейт в фронтенд-приложениях был локальным.

Потом людям понадобилось хранить и шарить какие-то данные между разными частями приложения. Начали изобретать шины событий, класть данные в window (или, ещё хуже - в куки) итд.

Потом поняли, что это превращается в неконтролируемый хаос. И чтобы строить нормальный отслеживаемый флоу + нормально подвязываться на обновления стейта, изобрели полноценные стейт-менеджеры.

Стейт-менеджеры вроде как умеют работать с бизнес-логикой самостоятельно, но многие всё равно рекомендуют выносить в них только глобальную логику. А остальную хранить локально, ведь не пропадать же добру.

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

А что сегодня?

А сегодня реальность такова, что вся бизнес-логика (даже самая примитивная) постоянно взаимодействует друг с другом, постоянно меняется. Сама логика также стала гораздо сложнее простого "сохрани эту переменную, чтобы использовать её там".

Нет больше такого понятия как "глобальная логика". Она вся "глобальная". И в 2021+ году хранить эту логику в компонентах не просто бессмысленно - это приносит ещё и много боли, не давая при этом никаких бенефитов.

Реальный пример

Чтобы не быть голословным, давай рассмотрим реальный пример, который проведёт нас через весь флоу размышлений и болей типичного разработчика.

Вот сегодня тебе поставили задачу запилить простой аккордеон-список.

Тут всё просто - пилим компонент, в нём делаем useState и отображаем описание, если он раскрыт.

const AccordionItem = ({ title, description }) => {
  const [isOpened, setIsOpened] = React.useState(false);

  return (
    <div onClick={() => setIsOpened(!isOpened)}>
      <h4>{title}</h4>
      {isOpened ? <p>{description}</p> : null}
    </div>
  );
};

«Дело сделано!» - подумал ты.

Завтра у тебя появляется кнопка "Открыть/Закрыть все".

«Блин блинский, придётся выносить эту логику выше, а в айтем прокидывать пропы. Ну ладно, разок перепишем, чо уж там...» - решаешь ты и идёшь переносить всё в AccordionList

const AccordionList = ({ list }) => {
  const [isOpened, setIsOpened] = React.useState([]);

  // Все ли айтемы открыты?
  const isAllItemsOpened = React.useMemo(() => {
    return isOpened.map((state) => Boolean(state));
  }, [isOpened]);

  // Тогглим один айтем
  const toggleOne = React.useCallback(
    (idx) => {
      const next = [...isOpened];
      next[idx] = !next[idx];
      setIsOpened(next);
    },
    [isOpened]
  );

  // Тогглим все айтемы
  const toggleAll = React.useCallback(() => {
    if (isAllItemsOpened) {
      setIsOpened(isOpened.map(() => false));
    } else {
      setIsOpened(isOpened.map(() => true));
    }
  }, [isAllItemsOpened]);

  // И не забываем обновить стейт, когда обновился список
  React.useEffect(() => {
    setIsOpened(list.map(() => false));
  }, [list]);

  return (
    <section>
      <div className="flex">
        <h4>Список </h4>
        <div className="ml-auto">
          <Button onClick={toggleAll}>
            {isAllOpened ? "Закрыть все" : "Открыть все"}
          </Button>
        </div>
      </div>
      {list.map((item, idx) => {
        return (
          <AccordionItem
            title={item.title}
            description={item.description}
            isOpened={isOpened[idx]}
            onToggle={() => {
              toggleOne(idx);
            }}
          />
        );
      })}
    </section>
  );
};

const AccordionItem = ({ title, description, isOpened, onToggle }) => {
  return (
    <div onClick={onToggle}>
      <h4>{title}</h4>
      {isOpened ? <p>{description}</p> : null}
    </div>
  );
};

Как-то жирновато, но ладно. На этом этапе, если ты не любишь лицезреть бойлерплейт, ты, скорее всего, вынесешь это в отдельный хук.

const useMassiveToggle = <T>(list: T[]) => { 
  const [isOpened, setIsOpened] = React.useState([]);

  // Все ли айтемы открыты?
  const isAllOpened = React.useMemo(() => {
    return isOpened.map((state) => Boolean(state));
  }, [isOpened]);

  // Тогглим один айтем
  const toggleOne = React.useCallback(
    (idx) => {
      const next = [...isOpened];
      next[idx] = !next[idx];
      setIsOpened(next);
    },
    [isOpened]
  );

  // Тогглим все айтемы
  const toggleAll = React.useCallback(() => {
    if (isAllOpened) {
      setIsOpened(isOpened.map(() => false));
    } else {
      setIsOpened(isOpened.map(() => true));
    }
  }, [isAllItemsOpened]);

  // И не забываем обновить стейт, когда обновился список
  React.useEffect(() => {
    setIsOpened(list.map(() => false));
  }, [list]);
  
  return [isOpened, { isAllOpened, toggleOne, toggleAll }]
}

Отлично. В хук вынесли, компонент стал почище, и вроде как чот переиспользуемое на проекте появилось.

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

Так или иначе, на часах полночь, и ты отправляешься баиньки.

Утром снова приходит бизнес и говорит, что при открытии ссылки с прокинутым lessonId в query нужно, чтобы юзеру открывало сразу конкретный аккордеон-айтем.

Опять эти чёртовы маркетологи вставляют палки в колёса. Ну ладно, раз сказали, значит, сделаем.

const AccordionList = ({ list }) => {
  const query = useQuery();
  const [isOpened, { isAllOpened, toggleOne, toggleAll }] = useMassiveToggle(list);

  React.useEffect(() => {
    if (query.lessonId) {
      toggleOne(query.lessonId);
    }
  }, [query.lessonId]);

  return (
    <section>
      <div className="flex">
        <h4>Список </h4>
        <div className="ml-auto">
          <Button onClick={toggleAll}>
            {isAllOpened ? "Закрыть все" : "Открыть все"}
          </Button>
        </div>
      </div>
      {list.map((item, idx) => {
        return (
          <AccordionItem
            title={item.title}
            description={item.description}
            isOpened={isOpened[idx]}
            onToggle={() => {
              toggleOne(idx);
            }}
          />
        );
      })}
    </section>
  );
};

Помимо просто открытия наверняка придётся написать ещё и скролл до этого элемента, но мне было влом это делать, так что представим, что эта логика тоже есть.

И всё вроде бы ничего, но потом бизнес приходит ещё раз, и ещё, и ещё... и с течением времени все компоненты превращаются в монстров по 200+ строк логики вперемешку с вёрсткой

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

В конце концов, бизнес приходит и просит справа от аккордеон-списка показывать какую-нибудь картинку и другие данные от текущего активного айтема. И ты идёшь выпиливать всю эту хрень и переписывать логику на Redux (или на любом другом стейт-менеджере).

Поддерживать такую кодовую базу сложно, неудобно; вносить изменения с каждым днём всё сложнее, и каждый последующий шаг сопровождается излишними переписываниями, вынесениями... а в последствии - прокрастинацией и выгоранием.

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

Так как решить эту задачу?

Во-первых, завести модель. Например, вот такую (это Effector, если что):

import { $lessonsList } from '@/entities/lesson';

export const lessonToggled = createEvent<number>();
export const allLessonsToggled = createEvent<void>();

export const $isOpened = createStore<boolean[]>([]);
export const $isAllOpened = $isOpened.map(states => states.every(Boolean));
  
$isOpened
  // Синхронизируем со списком уроков
  .on($lessonsList, (states, lessons) => lessons.map(() => false))
  // Тогглим один айтем
  .on(lessonToggled, (states, idx) => {
    const next = [...states]
    next[idx] = !next[idx]
    return next
  })
  // Тогглим все айтемы
  .on(sample($isAllOpened, allLessonsToggled), (states, isAllOpened) => {
    return states.map(() => isAllOpened ? false : true)
  });

А в компоненте чисто привязать соответствующие сторы и события:

const LessonsList = () => {
  const lessons = useStore($lessons);
  const isOpened = useStore($isOpened);
  const isAllOpened = useStore($isAllOpened);
  
  return (
    <section>
      <div className="flex">
        <h4>Список </h4>
        <div className="ml-auto">
          <Button onClick={allLessonsToggled}>
            {isAllOpened ? "Закрыть все" : "Открыть все"}
          </Button>
        </div>
      </div>
      {lessons.map((lesson, idx) => {
        return (
          <AccordionItem
            title={lesson.title}
            description={lesson.description}
            isOpened={isOpened[idx]}
            onToggle={() => {
              lessonToggled(idx);
            }}
          />
        );
      })}
    </section>
  );
};

Намного проще, правда? И компонент не засорён, и всю логику в удобном виде можно отдельно прочитать.

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

К примеру, чуть выше была модель кусочка с аккордеон-списком. А предварительное открытие элемента при открытии страницы с query-параметром можно положить в модель самой страницы:

import { $isOpened } from './accordion';
import { $lessonsList } from '@/entities/lesson';
import { $currentRoute } from '@/shared/routing';

/* 
   Читается так:
     Когда изменился $currentRoute,
     вызвать routeWithLessonOpened,
     если мы на lessonsRoute и в query есть lessonId
*/
const routeWithLessonOpened = guard({
  source: $currentRoute,
  filter: (route) => {
    if (route.name !== 'lessonsRoute') {
      return false;
    }
    if (!('lessonId' in route.query)) {
      return false;
    }
    return true;
  }
});

/*
  Читается так:
    Когда вызвался routeWithLessonOpened,
    берём $lessonsList
    и отправляем обновлённый массив в $isOpened
*/
sample({
  source: $lessonsList,
  clock: routeWithLessonOpened,
  fn: (lessons, { query }) => {
    return lessons.map(lesson => lesson.id === query.lessonId);
  },
  target: $isOpened
})

Конечно, если у вас на проекте Redux, то ситуация будет менее радужной - он славится своей адовой бойлерплейтностью, и писать на нём вообще что-либо тупо впадлу. Мне стало впадлу писать на Redux ещё 4 года назад на этапе прочтения документации, посему настоятельно рекомендую потрогать Effector.

Однако, даже писать бойлерплейт на Redux всё ещё лучше, чем засорить всю кодовую базу локальными стейтами и работой с ними. Ведь бизнес может придумать ещё 100500 задач:

  • Отправлять в аналитику инфу о том, что юзер кликнул на такой-то элемент
  • Заблокировать элементы списка, по которым ещё нет контента
  • Запоминать состояние раскрытых/закрытых элементов (к примеру, юзер вернулся на страницу курса с уроками и видит, на чём он остановился)
  • При раскрытии какого-то конкретного пункта показать попап с рекомендацией (да, это маркетинговая душнина, но и такое бывает)

Другие проблемы

Помимо того, что я расписал выше, есть ещё куча проблем, к которым приводит написание логики в компонентах.

Завязанность на вёрстку

Изменилась вёрстка и структура компонентов, но не изменилась логика - переписываем всё.

Логика стала работать с другими компонентами - переносим/выносим/переписываем

Если бы она сразу лежала отдельно, то работы было бы меньше, а иногда - и вовсе не было бы.

Ререндеры

Хуки - это круто, правда?

Ну, не совсем. Из-за своей императивности React by design не гарантирует стабильности их работы.

Ты можешь использовать useMemo/useCallback для избавления от лишних ререндеров.

Но это добавит кучу бойлерплейта. Сделает код трудночитаемым. А если у тебя не настроен линтер - ты можешь ещё и забыть добавить где-то зависимость. Кстати, в одном из сниппетов в этом посте я как раз забыл. Найдёшь?

Плюс ко всему, даже эти обёртки - не панацея. В любой момент React может дропнуть мемоизацию, если посчитает это необходимым:

Официальная дока реакта кста

Сама необходимость постоянно перечислять массив зависимостей и перепроверять, всё ли корректно работает, для меня уже красный флаг о том, что логику тут лучше не писать. Раньше мы юзали shouldComponentUpdate, потом заворачивали в React.memo(), теперь React.useМемы() . Доколе?

Логику приходится писать "высоко"

Также с локальным стейтом описание логики в компонентах приходится делать "высоко".

Если это форма - то все поля описываем в корне формы, а не отдельный стейт внутри каждого инпута. Меняем поле - получаем пересчёт всей формы. Где-то лишний спред, не завернул функцию в useCallback или пропустил мемы - всё, пошли ререндерить всё.

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

И это ещё один плюсик в копилку стейт-менеджеров - засчёт разбития на мелкие атомарные сторы (ну или использования селекторов) ты можешь подписать конкретный компонент на конкретный стор в самом низу - вплоть до того, что подписать каждый инпут на отдельный стор, и вся форма будет статичной:

export const Form = () => {
  return (
    <Form onSubmit={formSubmitted}>
      <Field label="Login">
        <LoginInput />
      </Field>
      <Field label="Password">
        <PasswordInput />
      </Field>
      <Stack>
        <Button onClick={formSubmitted}>Login</Button>
      </Stack>
    </Form>
  );
};

const LoginInput = () => {
  return <Input value={useStore($login)} onChange={loginChanged} />;
};

const PasswordInput = () => {
  return <Input type="password" value={useStore($password)} onChange={passwordChanged} />;
};

Неудобство тестирования

Если твоя логика описана в компоненте, то тебе придётся дёргать этот компонент, чтобы её протестировать.

Куда проще, если логика описана отдельно, и ты взаимодействуешь чисто с ней. А если нужно затестить вьюху, то отдельно тестишь вьюху. Мухи отдельно, котлеты отдельно.

А как же...

Новые стейт-менеджеры

С появлением хуков появилась куча новых стейт-менеджеров. Recoil, Zustand, Jotai итд.

Все они делают вид, что бизнес-логика описывается вне компонентов и всё хорошо. Однако, из-за своей дикой подвязанности на хуки "энтрипоинтом" всё ещё остается React-компонент.

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

Посмотри, сколько душного служебного кода нужно написать, чтобы просто зарефетчить данные о юзере:

// Храним userId
const currentUserIDState = atom({
  key: 'CurrentUserID',
  default: null,
});

// Служебный стейт для того, чтобы перезапустить селектор
const userInfoQueryRequestIDState = atomFamily({
  key: 'UserInfoQueryRequestID',
  default: 0,
});

// Почему-то он и занимается запросом, и хранит стейт userInfo
const userInfoQuery = selectorFamily({
  key: 'UserInfoQuery',
  get: userID => async ({get}) => {
    // Дёргаем служебный стейт, чисто чтобы перевызвать этот query
    get(userInfoQueryRequestIDState(userID));
    const response = await myDBQuery({userID});
    if (response.error) {
      throw response.error;
    }
    return response;
  },
});

// Очередной чисто служебный код 
// с кучей заворачиваний в какие-то функции
function useRefreshUserInfo(userID) {
  const setUserInfoQueryRequestID = useSetRecoilState(userInfoQueryRequestIDState(userID));
  return () => {
    setUserInfoQueryRequestID(requestID => requestID + 1);
  };
}

function CurrentUserInfo() {
  // И нагадим этим всем в компонент, разумеется
  const currentUserID = useRecoilValue(currentUserIDState);
  const currentUserInfo = useRecoilValue(userInfoQuery(currentUserID));
  const refreshUserInfo = useRefreshUserInfo(currentUserID);

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <button onClick={refreshUserInfo}>Refresh</button>
    </div>
  );
}

А могли бы разговаривать с бизнесом почти на одном языке, если бы не подвязывались на реакт, а использовали нормальный стейт-менеджер:

// Наш userId
const $userId = createStore(0);

// Наша userInfo
const $userInfo = createStore(null)
  .on(getUserFx.doneData, (prev, next) => next);  // Обновляем userInfo при завершении getUserFx

// Тот самый запрос
export const getUserFx = createEffect(async ({ userId }) => {
  const response = await myDBQuery({userID});
  if (response.error) {
    throw response.error;
  }
  return response;
});

// Можно сделать отдельный эффект и сразу прокинуть userId,
// чтобы не оперировать им в компоненте
const refreshUserFx = attach({
  source: { userId: $userId },
  effect: getUserFx
});

function CurrentUserInfo() {
  // Берём только нужную инфу
  const userInfo = useStore($userInfo);

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <button onClick={refreshUserFx}>Refresh</button>
    </div>
  );
}

Преждевременная оптимизация

Многие люди контр-аргументируют идею вынесения логики из компонентов словосочетанием "преждевременная оптимизация". Мол, мы тратим сильно больше времени на то, чтобы положить какую-то логику глобально; думаем над тем, куда это вынести, ожидая, что бизнес придёт, а он не приходит.

Да и потом, не зря же умные дядьки локальный стейт используют? Вон, даже react-query сделали, и целую коллекцию примитивных хуков настрочили.

Так вот...

  • Бизнес приходит всегда. И приходит часто. И приходит с самыми неожиданными вещами. Которые на словах для нешарящего в технической стезе человека могут звучать примитивно ("ну чё там, просто вот эту штуку отсюда вызвать"), а на деле получается, что пишем 3 месяца спиннер в гитлабе.
  • На написание логики вне компонента не нужно больше времени. Её просто нужно писать "чуть по-другому". По времени будет то же самое, а порой и вовсе меньше.
  • А вот на регулярные изменения и последующую поддержку кодовой базы, которая с каждым днём становится сложнее - уходит намного больше, чем по мнению этих же людей уходит на описание логики снаружи.
  • И на написание кучи служебного кода чисто из-за подвязанности на React (или любой другой view-слой) - тоже. Пример с Recoil тому яркий пример, а ведь это даже не какой-то суперсложный кейс.
  • Ну и потом, умные дядьки не всегда бывают умными. Но это отдельная история...

Единственное, о чём обязательно стоит позаботиться, вытаскивая логику из компонентов - это архитектура. Приложение должно быть грамотно декомпозировано на разные модули и уровни ответственности. Никаких components, reducers, hooks!!! Впрочем, это актуально и для любых проектов.

О том, как правильно разбивать приложение, расскажу в следующих постах.

Загрузить данные при открытии страницы

Очень распространённый якобы контр-пример - мы хотим загрузить данные при переходе на какую-то страницу. Ну или мы нажали на кнопку, развернулся какой-то блок, в котором что-то подгрузилось.

В случае с логикой в компонентах всё понятно - при рендере дёрнули запрос и положили в локальный стейт.

const PostsList = () => {
  const [posts, setPosts] = React.useState([]);
  
  React.useEffect(() => {
    api.get('/posts').then(setPosts);
  }, []);
  
  return <div>{/* ... */}</div>;
};

А как без компонентов? На самом деле, очень легко.

Инициатором всего является логика. Да, можно сказать, что запрос на подгрузку данных при открытии страницы инициализирует страница. Мол, страница открылась, она появилась, и вот мы отправляем запрос.

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

Тогда зачем грузить данные через посредника (страницу), если можно напрямую связать событие "изменился роут" с загрузкой:

Вместо вывода

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

Спасибо, что дочитали до этой строки!