Зберігати причини, виводити наслідки: проектування мінімального стану React
Навчіться виявляти зайвий стан у React, замінювати ланцюги синхронізації, засновані на Effect, та булеві флаги на похідні значення та об’єднання статусів, а також вирішувати, де має знаходитися стан.
Більшість компонентів 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 ефекти розглядаються як засіб для підтримки синхронності компонента з чимось, що не контролюється React, таким як API браузера, сокет чи не-React віджет. Коли ефект існує лише для того, щоб змінити один елемент стану компонента у відповідь на інший, поточна рекомендація — замислитися, чи взагалі потрібен цей другий елемент стану.
Існує ще один фактор витрат під час виконання, який легко пропустити. Кожен ефект виконується після того, як React вже завершив процес відображення, тож кожна ланка у ланцюзі спричиняє ще одне відображення з частково оновленими значеннями. Саме тому у початковій ситуації на мить з’являється повідомлення „жодних результатів“: під час одного процесу відображення новий список існує, але прапорець все ще відображає стару кількість.
У розрахованій версії взагалі немає такого ланцюга:
const filteredProducts = filterProducts(
products,
search,
category
);
const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;
Це не просто більш охайна синтаксис — це змінює спосіб мислення. За допомогою збереженого похідного стану ви відстежуєте, коли було останньо збережено кожне значення, чи вже виконався відповідний Effect, чи повний його масив залежностей, та чи є інші оновлення у черзі після нього. При використанні обчислень ви думаєте про вхідні та вихідні дані, і для чистих трансформацій це набагато простіший модель для підтримки. Якщо у вашому кодбазі вже є Effectи такого типу, поетапна рефакторизація, описана у Як припинити синхронізацію стану з 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 сказано те саме. Значення, які завжди змінюються разом, можна об’єднати, тоді як зайві, суперечливі, дубльовані чи глибоко вкладені дані слід скоротити. Об’єднання непов’язаних значень також має практичний недолік: кожне оновлення мусить розповсюджувати попередній об’єкт, і якщо про це забути, інші поля будуть безслідно видалені.
Отже, корисне запитання полягає не в тому, чи може набір значень поміститися в один об’єкт. Майже все може. Краще запитати:
Чи утворюють ці значення єдину послідовну частину стану, переходи між якою є взаємопов’язаними?
Якщо так, їх групування може зробити код зрозумілішим. Якщо ні, об’єднаний об’єкт лише ускладнює розуміння того, яка зміна впливає на що. Скорочення кількості станів полягає у видаленні дубльованих даних, а не у скороченні кількості компонентів до мінімуму.
Отримання значень без ігнорування продуктивності
Виведення відфільтрованого списку зі стану зазвичай викликає заперечення: чи не буде фільтр виконуватися під час кожного оновлення? Так, і для більшості повсякденних операцій це саме та оптимізація, яка потрібна. Фільтрація масиву середнього розміру є дешевою, а її виконання безпосередньо в коді зберігає простоту компонента без значних витрат.
Якщо аналіз показує, що певна операція справді є ресурсозатратною, наприклад обробка великого списку з фільтрацією та сортуванням, ви можете зберігати результат у кеші за допомогою 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-сховищ.
Reducer організують переходи, а не модель
Коли компонент містить багато функцій-встановлювачів, наступним кроком часто є перехід до 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-ів. Компонент, який має вісім незалежних, необхідних елементів стану, може бути ідеально спроєктованим, тоді як компонент із трьома змінними вже має занадто багато інформації, якщо дві з них є копіями чи наслідками третьої.
Ключові висновки
- Зберігайте причини, такі як введення користувача та його вибори; під час відображення отримуйте наслідки, такі як фільтровані списки, підрахунки та прапорці.
- Ефект, який лише встановлює стан на основі іншого стану, є ознакою того, що друге значення має бути результатом обчислень.
- Описуйте взаємовиключні режими як одне значення статусу, щоб не можна було представити неможливі комбінації.
- Групуйте значення лише тоді, коли вони змінюються разом; один великий об’єкт сам по собі не є метою.
- Використовуйте
useMemoпісля вимірювань та пам’ятайте, що це кеш, а не джерело істини. - Надайте спільним фактам єдиного власника на найнижчому спільному батьківському елементі та залишайте суто локальний стан інтерфейсу в межах цього елемента.
- Коли компонент стає складним для змін, перед додаванням ще одного функціоналу для зміни стану запитайте, який реальний факт представляє кожна змінна стану. Стан, який найлегше підтримувати у синхронності, — це той, який ви ніколи не зберігали.
Пов’язана література
- React Query та Redux: Переосмислення стану сервера у великих додатках — Дізнайтеся, чому додаток для чату у промислових умовах використовував TanStack Query замість Redux для керування даними сервера, та де все ще знаходить своє місце Redux у сучасній архітектурі React.
- Переосмислення стану React: Де насправді мають знаходитися ваші дані — У цій статті пояснюється, як зменшити кількість помилок у React шляхом розміщення стану в URL, DOM або похідних значень замість надмірного використання useState.