Всем привет! Из этой статьи вы узнаете о самых распространенных ошибках при создании React компонентов, а также о том, почему они считаются ошибками, как их избежать или исправить.
Оригинальный материал был написан немецким разработчиком Лоренцом Вайсом для личного блога, а позже собрал много позитивных отзывов на dev.to. Переведено командой Quarkly специально для комьюнити на Хабре.
React
React достаточно давно существует в мире веб-разработки, и его позиции как инструмента для гибкой разработки стремительно укрепились за последние годы. А после анонса и релиза нового хука api/concept создание React-компонентов стало ещё проще.
Несмотря на то, что команда, разработавшая React, и огромное сообщество, которое кодит на этом языке, попытались дать внушительное объяснение концепции React'а, я всё же нашел кое-какие недостатки и типичные ошибки во время работы с ним.
Я составил список всех ошибок, которые обнаружил за последние годы, особенно тех, что связаны с хуками. В этой статье я расскажу о самых распространенных ошибках, постараюсь подробно объяснить, почему я считаю их ошибками, и покажу, как их избежать или исправить.
Дисклеймер
Прежде чем перейти к списку, должен сказать, что на первый взгляд большинство ошибок из списка на самом деле не являются фундаментальными или даже выглядят вполне корректно. Большинство из них вряд ли вообще повлияют на работу или внешний вид приложения. Скорее всего, кроме разработчиков, работающих над продуктом, никто не заметит, что здесь что-то не так. Но я всё равно считаю, что хороший качественный код может помочь улучшить опыт разработки и, следовательно, разработать лучший продукт.
Что касается React-кода, как и по любому другому программному фреймворку или библиотеке, по нему существует миллион различных мнений. Всё, что вы видите здесь, — мое личное мнение, а не истина в последней инстанции. Если у вас другое мнение, я с удовольствием выслушаю его.
1. Использование useState, когда нет необходимости в повторном рендере
Одна из основных концепций React’а — это работа с состоянием. Вы можете контролировать весь поток данных и рендеринг через состояние. Каждый раз, когда дерево рендерится снова, оно, скорее всего, привязывается к изменению состояния.
С помощью хука useState вы можете также определять состояние в функциональных компонентах, потому что это действительно чистый и простой способ обработки состояний в React’е. На примере ниже можно увидеть, как этот хук используется не по назначению.
Теперь больше о примере: предположим, что у нас есть две кнопки, одна кнопка — счетчик, а другая отправляет запрос или запускает действие с текущим счетчиком. Однако текущий номер не отображается внутри компонента. Он требуется только для запроса при нажатии второй кнопки.
Так делать нехорошо:
function ClickButton(props) {
const [count, setCount] = useState(0);
const onClickCount = () => {
setCount((c) => c + 1);
};
const onClickRequest = () => {
apiCall(count);
};
return (
<div>
<button onClick={onClickCount}>Counter</button>
<button onClick={onClickRequest}>Submit</button>
</div>
);
}
Проблема:
На первый взгляд, вы можете спросить: А в чем, собственно, проблема? Разве не для этого было создано это состояние? И будете правы: всё отлично сработает, и проблемы вряд ли возникнут. Однако в React’е каждое изменение состояния влияет на компонент и, скорее всего, на его дочерние компоненты, то есть заставляет их выполнить повторный рендеринг.
В приведенном выше примере, так как мы не используем это состояние в нашей части рендера, каждый раз, когда мы будем устанавливать счетчик, это будет заканчиваться ненужным рендерингом. А это может повлиять на работу приложения или привести к неожиданным побочным эффектам.
Решение:
Если внутри вашего компонента вы хотите использовать переменную, которая должна сохранять свое значение между рендерингом, но при этом не вызывать повторный рендеринг, вы можете использовать хук useRef. Он сохранит значение, но не приведет к повторному рендерингу.
function ClickButton(props) {
const count = useRef(0);
const onClickCount = () => {
count.current++;
};
const onClickRequest = () => {
apiCall(count.current);
};
return (
<div>
<button onClick={onClickCount}>Counter</button>
<button onClick={onClickRequest}>Submit</button>
</div>
);
}
2. Использование router.push вместо ссылки
Наверное, это очевидно и не имеет никакого отношения к самому React’у, но я до сих пор довольно часто наблюдаю эту оплошность у людей, создающих React-компоненты.
Допустим, вы создаете кнопку, и пользователь при нажатии на неё должен быть перенаправлен на другую страницу. Так как это SPA, то это действие будет клиентским механизмом маршрутизации. Так что вам понадобится какая-нибудь библиотека. Самая популярная из них в React — это react-router, и в следующем примере будет использована именно эта библиотека.
Значит ли это, что добавление слушателя события по клику правильно перенаправит пользователя на нужную страницу?
Так делать нехорошо:
function ClickButton(props) {
const history = useHistory();
const onClick = () => {
history.push('/next-page');
};
return <button onClick={onClick}>Go to next page</button>;
}
Проблема:
Хотя для большинства пользователей это вполне рабочее решение, есть одна большая проблема: она появляется, когда дело касается реализации веб-доступности. Кнопка вообще не будет помечена как ссылка на другую страницу, а это значит, что скринридер не сможет ее идентифицировать. Сможете ли вы открыть ее в новой вкладке или окне? Скорее всего, тоже нет.
Решение:
Ссылки на другие страницы при любом взаимодействии с пользователем должны, насколько это возможно, обрабатываться компонентом <Link> или обычным тегом <a>.
function ClickButton(props) {
return (
<Link to="/next-page">
<span>Go to next page</span>
</Link>
);
}
Бонусы: это также делает код более читабельным и лаконичным!
3. Обработка действий с помощью useEffect
Один из лучших и продуманных хуков, представленных в React’е, — это useEffect. Он позволяет обрабатывать действия, связанные с изменением prop или state. Несмотря на свою функциональность, этот хук также часто используется там, где не очень-то и нужен.
Представьте себе компонент, который извлекает список элементов и выводит их в DOM. Помимо этого, в случае успешного выполнения запроса мы хотим вызвать функцию onSuccess, которая передается компоненту в качестве свойства.
Так делать нехорошо:
function DataList({ onSuccess }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const fetchData = useCallback(() => {
setLoading(true);
callApi()
.then((res) => setData(res))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (!loading && !error && data) {
onSuccess();
}
}, [loading, error, data, onSuccess]);
return <div>Data: {data}</div>;
}
Проблема:
Есть два хука useEffect: первый обрабатывает запрос данных к API во время первоначального рендеринга, а второй вызывает функцию onSuccess. То есть, если в состоянии нет загрузки или ошибки, но есть данные, то этот вызов будет успешным. Логично звучит, да?
Конечно, это почти всегда будет срабатывать с первым вызовом. Но вы также потеряете прямую связь между действием и вызываемой функцией. И нет 100% гарантии, что это произойдет только в том случае, если действие fetch будет успешным. А это именно то, что мы, разработчики, так сильно не любим.
Решение:
Самое простое решение — установить функцию onSuccess туда, где вызов будет успешным.
function DataList({ onSuccess }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const fetchData = useCallback(() => {
setLoading(true);
callApi()
.then((fetchedData) => {
setData(fetchedData);
onSuccess();
})
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, [onSuccess]);
useEffect(() => {
fetchData();
}, [fetchData]);
return <div>{data}</div>;
}
Теперь с первого взгляда понятно, что onSuccess вызывается только в случае успешного вызова API.
4. Компоненты с единой ответственностью
Составлять компоненты может быть довольно трудной задачей. Когда делить один компонент на несколько более мелких компонентов? Как структурировать дерево компонентов? Этими вопросами задаются все, кто каждый день работает с компонентным фреймворком. И самой распространенной ошибкой при создании компонентов является объединение двух случаев использования в один компонент.
Возьмем в качестве примера заголовок, который показывает либо кнопку бургерного меню на мобильном устройстве, либо вкладки на десктопе (условие будет обработано волшебной функцией isMobile, которая не является частью этого примера).
Так делать нехорошо:
function Header(props) {
return (
<header>
<HeaderInner menuItems={menuItems} />
</header>
);
}
function HeaderInner({ menuItems }) {
return isMobile() ? <BurgerButton menuItems={menuItems} /> : <Tabs tabData={menuItems} />;
}
Проблема:
При таком подходе компонент HeaderInner пытается вести двойную жизнь. А мы знаем, что тяжело быть Кларком Кентом и Суперменом одновременно. Это также затрудняет процесс тестирования или повторного использования компонента в других местах.
Решение:
Если перенесем условие на один уровень выше, будет проще увидеть, для чего сделаны компоненты, и что они отвечают только за одно: заголовок, вкладку или кнопку бургер-меню, и не стоит пытаться отвечать за несколько вещей одновременно.
function Header(props) {
return (
<header>{isMobile() ? <BurgerButton menuItems={menuItems} /> : <Tabs tabData={menuItems} />}</header>
);
}
5. useEffect с единой ответственностью
Помните времена, когда у нас были только методы componentWillReceiveProps или componentDidUpdate для подключения к процессу рендеринга React-компонента? В голову сразу приходят мрачные воспоминания, а ещё осознаешь всю прелесть использования хука useEffect и особенно то, что его можно использовать без ограничений.
Но иногда, когда используешь useEffect для некоторых вещей, мрачные воспоминания приходят вновь. Представьте, например, что у вас есть компонент, который извлекает некоторые данные из бэкэнда и отображает путь (breadcrumbs) в зависимости от текущего местоположения (снова используется react-router для получения текущего местоположения).
Так делать нехорошо:
function Example(props) {
const location = useLocation();
const fetchData = useCallback(() => {
/* Calling the api */
}, []);
const updateBreadcrumbs = useCallback(() => {
/* Updating the breadcrumbs*/
}, []);
useEffect(() => {
fetchData();
updateBreadcrumbs();
}, [location.pathname, fetchData, updateBreadcrumbs]);
return (
<div>
<BreadCrumbs />
</div>
);
}
Проблема:
Существует два варианта использования хука: «сбор данных» (data-fetching) и «отображение пути» (displaying breadcrumbs). Оба обновляются с помощью хука useEffect. Этот самый хук useEffect сработает, когда fetchData и updateBreadcrumbs функционируют или меняется location. Основная проблема в том, что теперь мы также вызываем функцию fetchData при изменении location. Это может стать побочным эффектом, о котором мы и не подумали.
Решение:
Если разделить эффект, станет понятно, что функции используются только для одного эффекта, при этом неожиданные побочные эффекты исчезают.
function Example(props) {
const location = useLocation();
const updateBreadcrumbs = useCallback(() => {
/* Updating the breadcrumbs*/
}, []);
useEffect(() => {
updateBreadcrumbs();
}, [location.pathname, updateBreadcrumbs]);
const fetchData = useCallback(() => {
/* Calling the api */
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<div>
<BreadCrumbs />
</div>
);
}
Бонусы: случаи использования теперь также логически распределены внутри компонента.
Заключение
При создании React-компонентов натыкаешься на множество подводных камней. Никогда не удается досконально понять весь механизм, и вы обязательно столкнетесь хотя бы с маленьким, а, скорее всего, даже с большим косяком. Однако совершать ошибки тоже нужно, когда изучаешь какой-нибудь фреймворк или язык программирования. Никто не застрахован от них на 100%.
Я думаю, что делиться своим опытом в этой сфере — очень полезно для других, это должно помочь другим ребятам не наступать на те же грабли.