Главная / Статьи / Чек-лист для проверки кода на React: производные состояния, редьюсеры, эффекты и Memo

Чек-лист для проверки кода на React: производные состояния, редьюсеры, эффекты и Memo

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

1906 слов

Разработчики с несколькими годами опыта работы с React обычно знают, как передаются пропсы, как работают хуки и почему состояние никогда не следует изменять напрямую. Однако при ревью кода в их пул-риквестах постоянно возникают одни и те же проблемы: компоненты, которые работают, но являются хрупкими, трудными для тестирования, чрезмерно сложными или затруднительными в расширении. Чтобы преодолеть этот этап, необходимо не столько изучать сложные API, сколько уточнить представление о том, где находится состояние, для чего используются определенные эффекты и когда оправдана оптимизация. В этом чек-листе рассматриваются семь часто встречающихся проблем на примере списка задач на TypeScript и показан подход, который следует использовать в каждом случае.

1. Состояние, которое следует вычислять

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

Как это выглядит

Список задач, в котором необходимы как все элементы, так и завершённые, часто приводит к наличию двух массивов в состоянии:

// ❌ BAD: Storing derived state in two separate places
const [todos, setTodos] = useState<Todo[]>([]);
const [completedTodos, setCompletedTodos] = useState<Todo[]>([]);

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

Что писать вместо этого

Храните один массив в состоянии и вычисляйте завершённую подмножину во время отрисовки:

// ✅ GOOD: Single source of truth with derived state
const [todos, setTodos] = useState<Todo[]>([]);
// Derived value recalculated on every render (inexpensive computation)
const completedTodos = todos.filter((todo) => todo.completed);

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

2. Ручной захват данных в useEffect

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

Как это выглядит

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

// ❌ BAD: Boilerplate-heavy, error-prone, no caching or deduplication
useEffect(() => {
  let isMounted = true;
  fetch('/api/todos')
    .then((res) => res.json())
    .then((data) => {
      if (isMounted) setTodos(data);
    });
  return () => { isMounted = false; };
}, []);

У него всё ещё есть недостатки. Нет обработки ошибок, нет состояния загрузки, отсутствует удаление дубликатов при запросе одних и тех же данных двумя компонентами, нет кэширования между экранами и нет возможности повторной попытки. Флаг isMounted также лишь предотвращает позднюю обновку; он не отменяет запрос, как это делает AbortController.

Что писать вместо этого

Используйте библиотеку, предназначенную для управления состоянием сервера, такую как TanStack Query, SWR или RTK Query. Аналогичный вызов в TanStack Query предусматривает указание ключа кэша и функции загрузки, после чего возвращаются данные, информация о статусе загрузки и сообщения об ошибках:

// ✅ GOOD: Declarative, automatically cached, auto-retried
const { data: todos, isLoading, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

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

3. Множество вызовов useState вместо редьюсера

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

Как это выглядит

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

Что писать вместо этого

useReducer перемещает переходы состояния из компонента в одну функцию. Приведённый ниже редьюсер использует дискриминированное объединение для своих действий, поэтому TypeScript знает тип данных для каждого случая. При добавлении задачи создаётся новый объект с генерированным идентификатором; при изменении статуса выполняется перебор списка и меняется соответствующий элемент. Обратите внимание, что в приведённой строке с изменением статуса есть опечатка: ...todo.completed должно быть !todo.completed, что обозначает логическое отрицание, иначе код не скомпилируется.

type Action =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: string };

function todoReducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: crypto.randomUUID(), text: action.payload, completed: false }];
    case 'TOGGLE_TODO':
      return state.map((todo) =>
        todo.id === action.payload ? { ...todo, completed: ...todo.completed } : todo
      );
    default:
      return state;
  }
}

Редьюсеры полезны по двум причинам:

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

4. Отсутствие тестов потому, что «тестирование — это крупный проект»

Многие команды относятся к тестированию как к вопросу «всё или ничего»: либо полная настройка тестирования от начала до конца, что кажется слишком сложным, либо вовсе ничего. Лучший подход заключается в том, чтобы сначала написать те тесты, которые легче всего создать и которые наиболее важно сохранить.

Определяйте приоритеты в следующем порядке:

  • Отдельная бизнес-логика. Вспомогательные функции, форматтеры, валидаторы и утилиты, находящиеся вне структуры компонентов.
  • Редьюсеры и пользовательские хуки. Чистая логика перехода состояния.
  • Критически важные сценарии использования пользователя. Основные интеракции, тестируемые с помощью React Testing Library (@testing-library/react).
  • Хорошим первым шагом является тестирование редьюсера. Он формирует начальное состояние, напрямую отправляет действие через функцию и проверяет результат, без необходимости отрисовки:

    // Example: Testing a reducer takes 2 minutes and provides huge peace of mind
    test('should toggle todo status', () => {
      const initialState = [{ id: '1', text: 'Write Article', completed: false }];
      const newState = todoReducer(initialState, { type: 'TOGGLE_TODO', payload: '1' });
    
      expect(newState[0].completed).toBe(true);
    });
    

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

    5. Использование useEffect для синхронизации React с самим собой

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

    Постоянно возникают две ошибочные представления:

    • «Состояние должно обновляться в эффекте при изменении свойства». Обычно это не требуется. Рассчитывайте значение во время отрисовки или, если состояние дочернего элемента должно сбрасываться, укажите ему key, который меняется вместе с соответствующим свойством, чтобы React перерисовал его.
    • «Логика обработки кликов должна находиться в эффекте». Логика, запускаемая действием пользователя, должна находиться в обработчике событий, таком как onClick, а не в эффекте, который отслеживает изменение состояния, вызванное кликом.

    Основной принцип: функции useEffect служат способом синхронизации с системами вне React, такими как API браузера, подписки, инструменты аналитики или внешние компоненты DOM. Данные, уже находящиеся в распоряжении React, можно преобразовывать без их использования. Подробнее с примерами до и после изменений см. статью о прекращении синхронизации состояния с useEffect.

    6. По умолчанию мемизировать всё

    Работа над производительностью часто сводится к двум крайностям: полное игнорирование затрат на отрисовку или обертывание каждого компонента функцией React.memo и каждой функции — функцией useCallback.

    Почему общее мемизирование имеет обратный эффект

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

    Более осознанный подход

    • Сначала проанализируйте работу приложения. Инструмент профилирования React DevTools показывает, какие компоненты отрисовываются слишком часто или слишком медленно.
    • Исправьте структуру перед добавлением мемоизации. Перенос состояния в тот компонент, который им пользуется, или передача дорогостоящих поддеревьев в качестве children часто позволяет избежать ненужных отрисовок без использования мемоизации.
  • Храните кэш там, где это важно. Применяйте React.memo к большим спискам и ресурсоемким диаграммам, а также используйте useCallback и useMemo, чтобы сделать их параметры стабильными.
  • 7. Рассматривание CSS и доступности как чужой задачи

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

    Области, которые стоит освоить:

    • Современное форматирование. Хорошо овладейте Flexbox и CSS Grid, чтобы абсолютное позиционирование больше не использовалось в качестве альтернативы для обычных макетов.
    • Локальное стилирование. Храните стили рядом с компонентами с помощью CSS Modules, Tailwind CSS или CSS-in-JS, вместо того чтобы полагаться на большие глобальные таблицы стилей, где имена классов конфликтуют.
  • Доступность и удобство использования. Используйте семантические элементы, такие как настоящий <button> вместо <div onClick={...}>. Убедитесь, что навигация с помощью клавиатуры (клавиши Tab, Enter и Space) работает везде. Укажите видимые стили :focus-visible и четко определите состояния интерактивности, например, курсор мыши над элементами, на которые можно кликнуть.
  • Чек-лист для проверки

    При рассмотрении pull request в React обращайте внимание на следующие замены:

    • Значения, получаемые посредством вычислений, хранятся в дополнительных хуках useState: лучше вычислять их во время отрисовки.
    • Ручные вызовы API внутри useEffect: используйте TanStack Query, SWR или RTK Query.
    • Множество вызовов useState для сложных массивов и объектов: перенесите логику транзиций в useReducer и протестируйте этот редьюсер.
  • Нет тестов, так как настройка кажется слишком дорогой: начните с чистых функций, редьюсеров и пользовательских хуков.
  • useEffect используется для синхронизации состояния: вычисляйте значение или обрабатывайте его в обработчике события.
  • useCallback и React.memo применяются везде: сначала проанализируйте эффективность, прежде чем использовать мемоизацию.
  • Элементы div, на которые можно нажимать, и отсутствие стилей для фокуса: используйте семантический HTML и проверьте доступность с клавиатуры.
  • Заключение

    Большинство этих проблем имеют одну общую причину: состояние или логика существует в большем количестве мест или выполняется чаще, чем это необходимо. Сохранение единого источника правды, использование специально созданных инструментов для работы с данными на сервере, изоляция переходов в чистых функциях и ограничение использования эффектов к внешнему миру — всё это сокращает количество элементов, которые нужно учитывать. Код с меньшим количеством таких элементов легче тестировать, его проще оптимизировать в нужных моментах, и он вызывает больше доверия у следующего проверяющего.