Хватит писать логику в компонентах
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>;
};
А как без компонентов? На самом деле, очень легко.
Инициатором всего является логика. Да, можно сказать, что запрос на подгрузку данных при открытии страницы инициализирует страница. Мол, страница открылась, она появилась, и вот мы отправляем запрос.
Но инциатор здесь всё равно не страница. А логика, которая предшествовала открытию страницы. В случае с роутом - это переход на роут. В случае с раскрытием какого-то блока - это нажатие на кнопку, которая его раскрывает итд.
Тогда зачем грузить данные через посредника (страницу), если можно напрямую связать событие "изменился роут" с загрузкой:
Вместо вывода
Не знаю, что сказать в заключение. Пока писал сей пост, я изрядно выдохся, так что вывод будет простым - не пишите стейты в компонентах, и будет вам счастье.
Спасибо, что дочитали до этой строки!