Детальное исследование 3 подводных камней React, с которыми сталкиваются разработчики

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

1. Невозможность передать допустимый ключ массивам элементов

Наверняка вы уже имели дело с этим: при отображении массива, чтобы превратить его в один из элементов React, нужно предоставить каждому дочернему элементу уникальный ключ.

Но почему это так? И всегда ли это так? Чтобы ответить на этот вопрос, напомню в общих чертах, как React работает “под капотом”.

Каждый раз, когда в приложении происходит обновление состояния или пропсов, React запускает алгоритм Diffing, который сравнивает старое дерево DOM с новым. Так он узнает, что изменилось и, в конечном итоге, что нужно перерендерить.

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

<ul>
<li>Pizza</li>
<li>Pasta</li>
</ul>

<ul>
<li>Pizza</li> {/* Тот же узел */}
<li>Pasta</li> {/* Тот же узел */}
<li>Tortellini</li> {/* Новый узел! */}
</ul>

Старое дерево выше, новое — ниже.

Как видите, обновление добавило узел в нижней части дочернего дерева. React хорошо справляется с этим, поскольку понимает, что первые два узла не изменились, а третий  —  новый. Однако рассмотрим другой сценарий:

<ul>
<li>Pizza</li>
<li>Pasta</li>
</ul>

<ul>
<li>Tortellini</li> {/* Новый узел! */}
<li>Pizza</li> {/* Новый узел! */}
<li>Pasta</li> {/* Новый узел! */}
</ul>

Все три узла считаются новыми.

Здесь возникает проблема: обновление добавило новый узел поверх дочерних элементов. Когда React последовательно сравнивает каждую “дочку”, он не находит совпадения. Это означает, что он также воссоздаст тех двух “дочек”, которые не изменились.

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

И не только это! Поскольку React не способен однозначно идентифицировать каждую “дочку”, в некоторых случаях он может все перепутать, в том числе и состояние “дочек”. Вот пример, который продемонстрирует подобную ситуацию: напишите что-нибудь для первого входа, а затем нажмите “Add New to Start” (“Добавить новое, чтобы начать”).

Вот тут-то и вступают в игру ключи. Посмотрим:

<ul>
<li key='122'>Pizza</li>
<li key='321'>Pasta</li>
</ul>

<ul>
<li key='101'>Tortellini</li> {/* Новый узел! */}
<li key='122'>Pizza</li> {/* Тот же узел */}
<li key='321'>Pasta</li> {/* Тот же узел */}
</ul>

React понимает, что есть только один новый узел.

С помощью ключей React теперь может определить наличие “дочек”, которые являются второй и третьей. При этом он понимает, что “первая дочка припоздала”. Он не будет воссоздавать всех имеющихся “дочек”, поскольку может правильно их сопоставить.

Но что, если использовать индекс в качестве ключа? React делает это по умолчанию. Он будет использовать индекс массива, пока вы не предоставите фактический уникальный ключ. Он все равно предупредит вас о проблеме, чтобы вы могли оперативно ее решить. Но почему индекс не является допустимым ключом? Рассмотрим пример:

<ul>
<li key={1}>Pizza</li>
<li key={2}>Pasta</li>
</ul>

<ul>
<li key={1}>Tortellini</li> {/* Новый узел! */}
<li key={2}>Pizza</li> {/* Новый узел! */}
<li key={3}>Pasta</li> {/* Новый узел! */}
</ul>

Как видите, первый дочерний элемент не распознается как новый, потому что ключ совпадает. Однако его содержимое изменилось, поэтому React воссоздаст узел. То же самое происходит и с другими дочерними элементами. Более того, вы можете столкнуться с той же проблемой путаницы состояний, о которой говорилось ранее.

Однако есть одно исключение, когда использование индекса не помешает,  —  когда вы уверены, что дочерние узлы не будут переупорядочены. Тем не менее, рекомендую использовать уникальный идентификатор, а индекс  —  лишь в крайних случаях.

Переходим к следующему!

2. Мутирование состояния

Как уже было сказано, рендеринг приложения вызывается пропсами и обновлениями состояния. Но как React узнает об обновлении состояния? В этом-то и заключается наша задача! Всякий раз, устанавливая состояние с помощью специального API, мы уведомляем React об этом. Например:

const [hungry, setHungry] = React.useState(true);

const onEat = () => {
setHungry(false); // Вызов setHungry() запускает рендеринг
};

Пока речь идет о примитивных данных, все довольно просто. Однако, когда нужно работать с непримитивными данными, может возникнуть путаница, и именно здесь часто случаются ошибки. Взгляните:

const [sandwich, setSandwich] = useState<Sandwich>({
lettuce: true,
onions: true,
tomato: true,
salami: true
});

const onRemoveOnions = () => {
sandwich.onions = false; // Да...нет
setSandwich(sandwich);
};

Что здесь не так? Мы знаем, что должны использовать функцию setSandwich() для React, чтобы вызвать повторный рендеринг. Но этого недостаточно. Мы также должны убедиться в том, что не изменим состояние, чтобы можно было сравнить старую и новую версии (помните алгоритм Diffing?). Состояние React является исключительно неизменяемым. Это означает, что нужно перезаписывать его значение каждый раз, когда вы хотите его изменить.

В данном случае React не увидит изменений, поскольку в результате мутации две версии стали одинаковыми. Допустим, вы отображаете содержимое sandwich в div: оно не будет обновлено. Следовательно, вы по-прежнему будете видеть рендеринг onions.

Как можно это исправить? Посмотрим на правильную форму ниже:

const [sandwich, setSandwich] = useState<Sandwich>({
lettuce: true,
onions: true,
tomato: true,
salami: true
});

const onRemoveOnions = () => {
setSandwich(state => ({ ...state, onions: false }));
};

Это нечто совершенно иное. Создается новый объект, который привносит значения предыдущего состояния (обратите внимание на то, как он получается из обратного вызова setSandwich), но также переопределяет свойство onions. Рендеринг теперь будет работать правильно, поскольку создается новый объект и сохраняется нетронутым старое состояние.

Теперь рассмотрим пример на массивах:

const [sandwiches, setSandwiches] = useState<Sandwich[]>([
{
lettuce: true,
onions: false,
tomato: true,
salami: true
}
]);

const onAddSandwich = (sandwich: Sandwich) => {
sandwiches.push(sandwich); // Не делайте этого
setSandwiches(sandwiches);
};

Та же история: мутирование массива не позволяет React заметить изменения. Теперь выполним это так, как положено: 

const [sandwiches, setSandwiches] = useState<Sandwich[]>([
{
lettuce: true,
onions: false,
tomato: true,
salami: true
}
]);

const onAddSandwich = (sandwich: Sandwich) => {
setSandwiches(state => [...state, sandwich]);
};

Тот же принцип: создаем новый массив, а старый оставляем нетронутым. Но это еще не все! С теми из вас, кто относится к продвинутым пользователям TypeScript, поделюсь лайфхаком, который поможет избежать ошибок в будущем.

const [sandwiches, setSandwiches] = useState<ReadonlyArray<Sandwich>>();

Как видите, в TypeScript есть удобный тип ReadonlyArray, в котором отсутствуют все ненужные изменяемые свойства. Теперь при написании push() будет возникать ошибка TSLint! Аналогичным образом можно использовать типы ReadonlyMap и ReadonlySet.

3. Огромные компоненты на выходе

Допустим, нужно внести небольшие изменения. Вы открываете целевой компонент и видите 500 строк кода. После этого вы тратите 80% усилий на то, чтобы обнаружить нужный элемент и ничего не нарушить.

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

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

Когда решите, что компонент практически готов, посмотрите на то, что у вас получилось, и подумайте над тем, что можно улучшить.

Вот что следует сделать.

1. Разделить ответственности. Никогда не забывайте об этом правиле. Компонент не должен выполнять слишком много задач. Использует ли компонент множество хуков? JSX превышает несколько сотен строк? Тогда, вероятно, придется все разбить на части.

Попробуйте визуально сделать разбивку. Спросите себя: “Нужно ли это здесь?” или “Стоит ли нагружать компонент этим?”. Здесь нет одного правильного пути  —  делайте то, что считаете нужным, и не спешите. После этого продолжайте разбивать код на более мелкие компоненты.

2. Использовать хуки. При переходе в компонент основное внимание должно быть сосредоточено на том, что возвращается, а именно на JSX. Основная цель компонента  —  отобразить некое подобие UI.

Однако обычно для достижения необходимого UI приходится управлять данными, поступающими из бэкенда, из локального хранилища, связанными с UI состоянием и логикой (формы, навигация, стили, анимация) и прочими параметрами.

При этом основное внимание необходимо сосредоточить на JSX. Поэтому советую свести к минимуму все остальное внутри компонента.

Есть несколько запросов fetch? Возможно, их стоит перенести в отдельные файлы в виде функций javascript или собственного хука. Или же воспользуйтесь такими библиотеками, как redux (с запросами RTK query) и react-query.

Есть какая-либо форма логики, которая берет на себя слишком много управления состоянием? Экстраполируйте все связанное с этим в пользовательский хук или используйте formik.

При управлении сложными состояниями структур стоит применять useReducer() вместо useState(). Это еще один способ экстраполировать управление состояниями и получить дополнительные преимущества.

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

Теперь предлагаю рассмотреть реальный пример этого процесса, демонстрирующий на практике большинство приведенных выше советов:

const App = () => {
const [{ pizza, drink }, setFormData] = useState<FormData>(initialFormData);
const [submitResponse, setSubmitResponse] = useState<Response>();

useEffect(() => {
// Совершите какие-нибудь действия с данными...
}, [submitResponse]);

const onFormSubmit: FormEventHandler = async (e) => {
e.preventDefault();
const response = await fetch('https://example.com', {
method: 'POST',
body: JSON.stringify({ pizza, drink })
});
const data = await response.json();
setSubmitResponse(data);
};

const onFormReset = () => {
setFormData(initialFormData);
};

const onSelectChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
setFormData(form => ({
...form,
[e.target.id]: e.target.value
}))
};

return (
<div>
<header>
Alen's Pizza since 2022

<p>
Are you looking for the best pizza in town? This is the right place!
Alen's Pizza has been serving happy customers ever since 2022.
Open every day 18PM - 24PM
</p>
</header>

<form onSubmit={onFormSubmit} onReset={onFormReset}>
<div>
<label htmlFor='pizza'>Pizza</label>
<select
id='pizza'
name='pizza'
value={pizza}
onChange={onSelectChange}
>
<option value=''>Select a pizza...</option>
<option value='margherita'>Margherita</option>
<option value='marinara'>Marinara</option>
<option value='funghi'>Funghi</option>
<option value='boscaiola'>Boscaiola</option>
</select>
</div>

<div>
<label htmlFor='drink'>Drink</label>
<select
id='drink'
name='drink'
value={drink}
onChange={onSelectChange}
>
<option value=''>Select a drink...</option>
<option value='coke'>Coke</option>
<option value='orange_soda'>Orange soda</option>
<option value='lemonade'>Lemonade</option>
</select>
</div>

<button type='submit'>Submit order</button>
<button type='reset'>Reset order</button>
</form>

<footer>
Follow us
<ul>
<li><a href='#'>Facebook</a></li>
<li><a href='#'>Instagram</a></li>
<li><a href='#'>Twitter</a></li>
</ul>
</footer>
</div>
);
};

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

Имейте в виду, что это всего лишь пример. Следовательно, компонент не такой уж длинный или сложный, но все равно будем применять наши принципы для наглядности. Как видите, вместе с формой описаны кодом “шапка” и нижний колонтитул, так что рефакторинг определенно не помешал бы.

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

Посмотрим на результат:

const App = () => {
const { post, response } = useApi();
const { data, onSelectChange, onSubmit, onReset } = useForm({
onSubmit: (data) => {
post(data);
}
});

useEffect(() => {
// Совершите какие-нибудь действия с данными...
}, [response]);

return (
<div>
<Header />

<PizzaForm
value={data}
onSelectChange={onSelectChange}
onSubmit={onSubmit}
onReset={onReset}
/>

<Footer />
</div>
);
};

Теперь гораздо проще понять, что происходит.

Вот что я сделал: прежде всего перенес “шапку”, форму и нижний колонтитул в три отдельных компонента. Что касается формы, то пропсы, которые вы видите, позволяют ей “общаться” с родительским компонентом, как и раньше. Так что это была самая простая часть. Теперь посмотрим на новые хуки.

Я экспортировал всю логику в два отдельных хука: useForm() для управления состоянием и событиями формы и useApi() для отправки формы и передачи ее с помощью метода POST. Вот как они выглядят:

const useForm = (params?: { onSubmit?: (data: FormData) => void }) => {
const [data, setFormData] = useState<FormData>(initialFormData);

const onSubmit: FormEventHandler = async (e) => {
e.preventDefault();
params?.onSubmit?.(data);
};

const onReset = () => {
setFormData(initialFormData);
};

const onSelectChange: ChangeEventHandler<HTMLSelectElement> = (e) => {
setFormData(form => ({
...form,
[e.target.id]: e.target.value
}))
};

return {
data,
onSelectChange,
onSubmit,
onReset
};
};

Хук useForm() обрабатывает состояние значения формы и события.

const useApi = () => {
const [response, setResponse] = useState<Response>();

const post = async (formData: FormData) => {
const response = await fetch('https://example.com', {
method: 'POST',
body: JSON.stringify(formData)
});
const data = await response.json();
setResponse(data);
};

return { post, response };
};

Хук useApi() предоставляет функцию post и возвращает релевантный ответ.

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Alen Ajam: A Deep Dive Into the 3 React Pitfalls That Developers Fall Into

Предыдущая статья5 функций CLI на Rust для оптимизации привычных инструментов
Следующая статья5 причин грядущего господства Go в мире программирования