Главная / Статьи / Хранение причин, вывод последствий: проектирование минимального состояния React

Хранение причин, вывод последствий: проектирование минимального состояния React

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

3592 слов

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

Как простой список продуктов накапливает состояние

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

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");

  // ...
}

Затем поступают запросы на дополнительные функции. Чтобы таблица отображала соответствующие товары, кто-то добавляет переменную состояния для хранения отфильтрованного списка:

const [filteredProducts, setFilteredProducts] = useState(products);

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

const [resultCount, setResultCount] = useState(products.length);

Когда результатов нет, на странице должно отображаться сообщение об отсутствии данных, поэтому для этого тоже добавляется флаг:

const [hasResults, setHasResults] = useState(true);

Далее осуществляется сортировка, при этом сохраняются как выбранный критерий сортировки, так и отсортированная копия списка:

const [sortBy, setSortBy] = useState("name");
const [sortedProducts, setSortedProducts] = useState(products);

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

Компонент содержит множество состояний, но теперь он больше не может ответить на единственный важный вопрос: какое из этих значений является истинным?

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

Каждое сохранённое значение — это ещё один способ ошибиться

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

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

const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [filteredProducts, setFilteredProducts] = useState(products);
const [resultCount, setResultCount] = useState(products.length);
const [hasResults, setHasResults] = useState(true);

У каждой переменной есть понятное название, но они не являются независимыми друг от друга. Отфильтрованный список зависит от трех входных параметров:

products + search + category

Количество элементов зависит от отфильтрованного списка:

filteredProducts.length

А флаг пустого состояния зависит от количества элементов:

resultCount > 0

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

filteredProducts = []
resultCount = 4
hasResults = true

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

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

Важно понимать, что речь не идёт о «меньшем количестве вызовов useState». Речь идёт о смене подхода к тому, что считается состоянием:

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

Храните входные данные в состоянии, а остальное вычисляйте

Странице товара никогда не было нужно хранить значение resultCount. Ей нужно запоминать только то, что ввёл пользователь в поле поиска и какую категорию он выбрал. Это решения, принятые человеком, и ничто в данных о товаре не может их воссоздать. Всё остальное следует из этого:

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");

  const filteredProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(search.toLowerCase());
    const matchesCategory =
      category === "all" || product.category === category;
    return matchesSearch && matchesCategory;
  });
  const resultCount = filteredProducts.length;
  const hasResults = resultCount > 0;
  // ...
}

Обратите внимание, что исчезло. Нет необходимости обновлять resultCount; у hasResults нет метода-установщика, к тому же больше не существует случаев, когда какой-либо обработчик обновлял бы отфильтрованный список, но забывал обновить счётчик. При каждой отрисовке просто пересчитываются результаты на основе текущих данных. Новая строка поиска генерирует новый список, новая категория — тоже новый список, а если родительский компонент передаёт другой массив products, то в расчётах просто используется этот массив.

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

В документации React та же идея иллюстрируется с помощью поля fullName, формируемого из имени и фамилии: если его можно вычислить во время отрисовки, отдельная переменная состояния не приносит никакой пользы, кроме риска несоответствий.

Быстрый тест, который можно применить во время ревью кода:

If I deleted this state variable,
could I reconstruct its value exactly
from current props and other state?

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

Когда useEffect превращается в конвейер синхронизации

Частой реакцией на устаревшее производное состояние является использование useEffect для автоматического обновления копии. Тогда компонент product будет выглядеть примерно так:

const [filteredProducts, setFilteredProducts] = useState(products);

useEffect(() => {
  const nextProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(search.toLowerCase());
    const matchesCategory =
      category === "all" || product.category === category;
    return matchesSearch && matchesCategory;
  });
  setFilteredProducts(nextProducts);
}, [products, search, category]);

Затем второй эффект обеспечивает согласованность количества элементов с списком:

useEffect(() => {
  setResultCount(filteredProducts.length);
}, [filteredProducts]);

А, возможно, третий эффект управляет флагом пустого состояния:

useEffect(() => {
  setHasResults(resultCount > 0);
}, [resultCount]);

Вместе они образуют небольшую внутреннюю цепочку обработки:

products/search/category
        ↓
filteredProducts
        ↓
resultCount
        ↓
hasResults

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

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

В версии с расчётами цепочка вообще отсутствует:

const filteredProducts = filterProducts(
  products,
  search,
  category
);

const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;

Это не просто более аккуратная синтаксис — это изменение подхода к решению задач. При использовании хранимого производного состояния необходимо отслеживать, когда было в последний раз записано каждое значение, выполнялся ли уже соответствующий Effect, полна ли его таблица зависимостей и есть ли ещё обновления в очереди после него. При использовании вычислений достаточно учитывать входные и выходные данные, что делает такую модель гораздо проще в поддержке для чистых преобразований. Если в вашем кодбейсе уже есть Effect такого типа, пошаговая рефакторизация в статье Stop Syncing State with useEffect объясняет, как безопасно их устранить.

Замените булевые флаги на единый статус

Дублирующиеся значения — это один из проявлений избыточного состояния. Другой пример — когда одна концепция представлена несколькими независимыми булевыми переменными. Классическим примером является отправка формы:

const [isIdle, setIsIdle] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

Процесс должен находиться ровно на одном из четырех этапов:

idle
submitting
success
error

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

isSubmitting = true
isSuccess = true

Или она может одновременно сообщать о успехе и неудаче:

isSuccess = true
isError = true

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

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

type Status =
  | "idle"
  | "submitting"
  | "success"
  | "error";

const [status, setStatus] = useState<Status>("idle");

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

const isSubmitting = status === "submitting";
const isSuccess = status === "success";
const isError = status === "error";

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

Полезность растет по мере увеличения размера компонента. Процесс оформления заказа может проходить через эти фазы:

editing
validating
submitting
confirmed
failed

Импортер файлов также может проходить через эти фазы:

idle
uploading
processing
completed
failed

Когда у компонента существуют режимы, которые исключают друг друга, следует включить это исключение в модель состояния вместо того, чтобы делать из него правило, которому должен следовать каждый обработчик. Именно вы решаете, какие состояния может представлять программа, и это решение требует такого же внимания, как и сама структура данных. Однако есть один нюанс: если какой-либо этап содержит данные, например сообщение об ошибке, существующее только в этапе failed, использование дискриминированного союза объектов позволяет сохранить эти данные в соответствующем этапе, вместо того чтобы добавлять ещё одну свободную переменную.

Меньше переменных — это не то же самое, что один большой объект

Как только команда слышит фразу «сократить состояние», возникает соблазн перегнуть палку и поместить всё в один объект:

const [state, setState] = useState({
  search: "",
  category: "all",
  selectedProductId: null,
  sidebarOpen: false,
  page: 1,
});

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

search и category меняются по своим собственным графикам, а sidebarOpen не связан ни с одним из них. Использование отдельных переменных делает каждое обновление очевидным в месте его вызова:

const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [selectedProductId, setSelectedProductId] =
  useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);

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

Поэтому важный вопрос заключается не в том, может ли набор значений поместиться в один объект. Почти всё можно. Лучше спросить:

Образуют ли эти значения единое целостное состояние, переходы в котором связаны между собой?

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

Получение значений без ущерба для производительности

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

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

const filteredProducts = useMemo(() => {
  return products
    .filter((product) => {
      const matchesSearch = product.name
        .toLowerCase()
        .includes(search.toLowerCase());

       const matchesCategory =
        category === "all" ||
        product.category === category;
      return matchesSearch && matchesCategory;
    })
    .sort(compareProducts);
}, [products, search, category, sortBy]);

Обратите внимание, что useMemo не изменяет. Переменная filteredProducts снова не стала изменяемым состоянием. Она по-прежнему является чистой функцией своих входных данных; мемоизация лишь определяет, может ли React повторно использовать предыдущий результат в процессе отрисовки вместо его пересчёта. Это позволяет сохранять корректность и оптимизацию как отдельные аспекты. В документации React useMemo представлен исключительно как инструмент для оптимизации производительности, и в ней предупреждается о недопустимости полагаться на него для обеспечения корректного поведения, поскольку React может удалять значения из кэша.

Порядок действий, вытекающий из этого:

First make the state model correct.

Then measure.

Then optimize expensive calculations if necessary.

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

Размещайте каждый элемент состояния там, где принимаются совместные решения

Даже состояние, которое действительно необходимо, может вызвать проблемы, если оно находится в неправильном компоненте. Предположим, что каждая строка с продуктом отслеживает свой собственный выбор:

function ProductRow({ product }) {
  const [selected, setSelected] = useState(false);

  // ...
}

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

function ProductTable({ products }) {
  const [selectedProductId, setSelectedProductId] =
    useState<string | null>(null);

   return products.map((product) => (
    <ProductRow
      key={product.id}
      product={product}
      selected={product.id === selectedProductId}
      onSelect={() => setSelectedProductId(product.id)}
    />
  ));
}

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

selectedProductId

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

Ничто из сказанного не оправдывает размещения всего в корне приложения. Следует указать, что все остальное должно оставаться локальным. Наличие подсказки не имеет никакого отношения к данным аутентификации, а полузаполненное поле формы редко требует использования глобального хранилища. Управление состоянием проще всего, когда его владелец совпадает с тем, насколько широко распространяется соответствующее решение. Если разместить его слишком низко, компоненты будут дублировать одни и те же данные; если слишком высоко, отдаленные части приложения будут перерисовываться из-за изменений, которые для них неактуальны. Нахождение этой границы — важная часть хорошего проектирования состояния, и статья Rethinking React State: Where Your Data Should Actually Live более подробно рассматривает варианты локального, общего, серверного и URL-хранилища.

Редьюсеры организуют переходы, а не модель

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

setStatus("submitting");
setError(null);
setLastAttempt(Date.now());

вы описываете произошедшее как единое событие:

dispatch({ type: "submitted" });

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

const initialState = {
  search: "",
  products: [],
  filteredProducts: [],
  resultCount: 0,
  hasResults: true,
};

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

Лучший редьюсер сохраняет только входные данные:

const initialState = {
  search: "",
  category: "all",
  sortBy: "name",
};

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

Чек-лист для проверки состояния компонента

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

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

  • Отражает ли оно решение, принятое пользователем или системой?
  • Нужно ли компоненту сохранять это значение между перерисовками?
  • Можете ли вы воссоздать его точно на основе текущих параметров или остальных данных состояния?
  • Существует ли какая-либо последовательность событий, при которой он противоречит другому сохранённому значению?
  • У какого-либо другого компонента есть копия того же факта?
  • Является ли он собственностью компонента, чей поддерево фактически использует то же решение?
  • Эти вопросы дают гораздо больше информации, чем простой подсчёт Hook’ов. Компонент, содержащий восемь независимых и необходимых элементов состояния, может быть идеально спроектирован, тогда как компонент с тремя переменными уже имеет слишком много данных, если две из них являются копиями или следствием третьей.

    Основные выводы

    • Храните причины, такие как ввод пользователя и его выборы; результаты, такие как отфильтрованные списки, подсчёты и флаги, вычисляйте во время отрисовки.
    • Если Effect используется только для установки состояния на основе другого состояния, это указывает на то, что второе значение должно быть результатом вычислений.
    • Моделируйте взаимоисключающие режимы как одно значение состояния, чтобы невозможные комбинации не могли быть представлены.
    • Группируйте значения только тогда, когда они меняются вместе; один большой объект сам по себе не является целью.
    • Используйте useMemo после измерения и помните, что это кэш, а не источник истины.
    • Для общих данных укажите единого владельца на самом нижнем общем родителе, а чисто локальное состояние интерфейса оставьте локальным.
    • Когда компонент становится сложным для изменения, прежде чем добавлять ещё один метод установки значений, спросите, какое реальное явление представляет каждая переменная состояния. Состояние, которое легче всего синхронизировать, — это то, которое вы вообще не хранили.

    Связанная литература

  • Как незначительные решения влияют на долговечные кодбазы React — Пятнадцать привычек технического обслуживания для React-приложений, существующих много лет: читаемый код, сфокусированные компоненты, ограниченное состояние, минимальное количество зависимостей, тесты и мониторинг.
  • Выбор подходящего инструмента для React: Derive, Handle, Fetch, Defer или Effect — Руководство по принятию решений при замене рефлексивных вызовов useEffect на производные значения, обработчики событий, слой данных, useTransition, useMemo с параметром measured и API React 19.
  • Проектирование приложений в Angular для обработки неудачных запросов: состояния, интерцепторы и повторные попытки — Узнайте, как классифицировать ошибки HTTP в Angular, оптимизировать состояние загрузки, централизовать обработку в интерцепторах, безопасно повторять попытки и отображать сообщения, которые могут помочь пользователям принять решение.
  • Чек-лист для проверки кода в React: производные состояния, редюсеры, эффекты и мемоизация — Научитесь выявлять семь распространенных проблем при проверке кода в React, от дублирования состояний и ручного получения данных до неправильного использования эффектов и чрезмерной мемоизации, а также что писать вместо этого.