Переосмислення стану React: де насправді мають знаходитися ваші дані
Компанія React має менше помилок завдяки меншому використанню useState: переміщуйте стан у URL, DOM або похідні значення, та вирішуйте, хто відповідає за кожну частину, перш ніж вдаватися до механізмів керування станом.
Джерела: Stop Adding React State: It’s Creating Bugs You Don’t Need
Продавець у динамічно розвиваючійся компанії типу SaaS присвячує значну частину ранку перегляду бази даних клієнтів, яка містить десять тисяч облікових записів.
Після ретельного фільтрування він виділяє корпоративних клієнтів з Європи, чиї підписки не були продовжені протягом останніх дев’яноста днів.
У результаті утворюється точний список з тридцяти облікових записів, які потребують негайної уваги.
Він бере URL безпосередньо з браузера та надсилає його через Slack своєму регіональному директорові з приміткою:
"Ось облікові записи пріоритету на сьогоднішній ранок."
Директор відкриває посилання.
Сторінка завантажується.
Але замість відфільтрованого списку там відображається стандартний перегляд: усі десять тисяч облікових записів, без фільтрації та без сортування.
Увесь цей ретельний фільтраційний механізм зник, тому що інженер, який створював сторінку, зберіг кожен фільтр, поле сортування та значення сторінкування у стані локального компонента за допомогою useState.
Цей сценарій ілюструє патерн, який постійно зустрічається у кодових базах React.
Ми схильні використовувати стан лише тому, що React значно спрощує його створення.
Потрібне значення фільтра?
useState.
Потрібно відстежувати, яка вкладка активна?
useState.
Потрібний лічильник?
useState.
Потрібно зберігати дані форми?
useState.
Потрібно відстежувати різні етапи виклику API?
Це ще три виклики useState.
Незабаром один компонент опікується десятками значень, які спочатку зовсім не потребували зберігання у стані React.
Сам стан тут не є ворогом. Кожному складному додатку на React він потрібен.
Справжня проблема полягає у розміщенні стану там, де йому не належить.
Кожна додаткова змінна стану — це ще одна річ, яку ваш додаток мусить синхронізувати з усім іншим.
Якщо це не контролювати, це може спричинити низку проблем, серед яких:
- Додаткові зусилля для підтримки синхронності дублікатних значень
- Дані, які стають застарілими в якомусь кеші
- Значення, що відображаються на екрані та більше не відповідають реальності
- Оновлення, які відбуваються, коли насправді нічого значущого не змінилося
- Проблеми з таймінгом, коли два оновлення зіткнулися непередбачувано
- Ефекти, які ускладнюються через спробу підтримки консистентності всього
- Баги, виявлення причин яких займає набагато більше часу
- Сторінка, яка забуває все в момент перезавантаження
Створення відмінного React-коду насправді не полягає у оволодінні складною логікою стану.
Часто йдеться про те, щоб зрозуміти, коли взагалі не створювати цей стан.
1. URL — найкращий менеджер стану вашого додатку
Йому, ймовірно, потрібно відстежувати:
- Пошук
- Фільтри
- Сортування
- Сторінкування
- Активні вкладки
- Вибрані категорії
- Діапазони дат
Зазвичай основним підходом є зберігання всього цього у стані компонента.
function ProductList() {
const [activeCategory, setActiveCategory] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
// Fetch and filter data...
}
На перший погляд це здається абсолютно нормальним.
Потім користувач натискає «Оновити».
Кожне значення повертається до свого початкового стану.
Або вони діляться посиланням з колегою.
Цей колега потрапляє на зовсім інший екран.
Або вони натискають кнопку «Назад» у браузері.
Фільтрація, яку вони налаштували, може не повернутися, оскільки стан React не має вбудованого зв’язку з історією навігації браузера.
useState сам по собі тут не винен.
Справжня проблема полягає у тому, що URL вже існує як механізм для представлення стану навігації, і його повністю ігнорують.
Замість того, щоб писати:
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [category, setCategory] = useState('all');
ви можете закодувати ту саму інформацію безпосередньо в URL.
Щось на кшталт:
/products?category=enterprise&page=3&query=react
Використовуючи бібліотеки маршрутизації, такі як Next.js, React Router чи TanStack Router, ви можете отримати ці параметри безпосередньо з URL:
import { useSearchParams } from 'next/navigation';
function ProductList() {
const searchParams = useSearchParams(); const category = searchParams.get('category') || 'all';
const page = Number(searchParams.get('page')) || 1;
const query = searchParams.get('query') || ''; // Fetch data using the URL state...
}
Ця невелика зміна драматично впливає на життєвий цикл цього стану.
Оновити сторінку?
Фільтри залишаються незмінними.
Поділитися посиланням?
Хто б його не відкрив, побачить абсолютно той самий вигляд.
Зберегти як закладку?
Пізніше воно завантажиться у тому самому стані.
Натиснути «Назад»?
Браузер правильно перемикається між попередніми станами.
Це справжня перевага з точки зору архітектури.
Стан URL дає більше, ніж просто збереження даних
Здатність вижити після оновлення сторінки — це лише частина переваг.
Більшою перевагою є те, що URL стає портативним знімком поточного стану вашого додатку, який можна поділитися з будь-ким.
Візьмемо панель керування для обслуговування клієнтів як приклад:
/support/tickets?status=open&priority=high&team=billing
Один менеджер може миттєво надіслати це саме посилання іншому менеджеру.
Панель керування, орієнтована на продажі, може виглядати так:
/accounts?region=europe&plan=enterprise&renewal=90
Інструмент аналітики може використовувати щось подібне:
/analytics?range=30d&metric=revenue&groupBy=region
Жоден з цих рядків запиту не є довільним.
Кожен з них — це глибокий посилання, яке веде до конкретного, можливого до відновлення стану додатку.
Саме для цього і створювалися URL-адреси.
Коли стан URL має сенс
Параметри URL чудово підходять для таких речей, як:
- Те, що користувач ввів у поле пошуку
- Активні вибори фільтрів
- Колонки та напрямок сортування даних
- Яка сторінка результатів відображається
- Який таб активний наразі
- Вибраний діапазон дат
- Який збережений вигляд зараз відкритий
- Налаштування створеного звіту
- Індивідуальна налаштовка панелі керування
- Будь-який стан інтерфейсу, який користувач може хотіти передати іншій особі
Проте не все підходить для використання тут.
Паролі, очевидно, не повинні бути в URL.
Так само не слід використовувати тимчасовий стан модальної анімації.
Загалом кажучи, не варто також використовувати великі об’єкти з глибокою ієрархією.
Ключове запитання є простим:
Запитайте себе, чи може користувач розумно очікувати, що цей елемент буде можливим до поширення, додавання у закладки чи відновлення — якщо так, то URL, ймовірно, є правильним місцем для зберігання цього стану.
2. Припиніть зберігати значення, які можна просто обчислити
Швидкий спосіб скоротити обсяг стану в React — це припинити зберігати значення, які насправді походять від інших значень.
Розгляньмо цей приклад:
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
А в іншій частині компонента:
setItems(newItems);
setItemCount(newItems.length);
Тепер у вас є дві окремі змінні стану, які описують одну й ту саму основну інформацію.
Якщо items оновлюється, але itemCount не оновлюється відповідно, ваш інтерфейс буде відображати неточну інформацію.
Ви створили проблему синхронізації, якої взагалі не потрібно було існувати.
Більш чиста версія виглядає так:
const [items, setItems] = useState([]);
const itemCount = items.length;
Тепер існує єдине джерело правди.
Кількість обчислюється щоразу, коли компонент відображається.
Ця сама логіка застосовується під час фільтрації колекцій.
Замість того, щоб писати:
const [items, setItems] = useState([]);
const [activeItems, setActiveItems] = useState([]);
зазвичай можна просто написати:
const activeItems = items.filter(item => item.active);
Або:
const totalPrice = items.reduce(
(total, item) => total + item.price,
0
);
Або:
const hasErrors = fields.some(field => field.error);
Або:
const completedCount = todos.filter(todo => todo.completed).length;
Питання, яке варто поставити, — це не:
"Чи можу я технічно зберегти це у стані?"
Звісно, можете.
Але насправді варто запитати:
"Чи має це значення власний окремий життєвий цикл?"
Якщо ні, ймовірно, воно не повинно бути частиною стану.
3. Припиніть підключати кожне поле форми до стану
Ще одна звичка, яку варто позбутися, — це розглядати кожне поле форми як окрему змінну стану.
Невелика форма, побудована таким чином, може виглядати ось так:
function ContactForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); submitData({
firstName,
lastName,
email,
message
});
}; return (
<form onSubmit={handleSubmit}>
<input
value={firstName}
onChange={e => setFirstName(e.target.value)}
/> <input
value={lastName}
onChange={e => setLastName(e.target.value)}
/> <input
value={email}
onChange={e => setEmail(e.target.value)}
/> <textarea
value={message}
onChange={e => setMessage(e.target.value)}
/> <button type="submit">Send</button>
</form>
);
}
Технічно тут немає нічого неправильного.
Контрольовані введення стають корисними тоді, коли ваш додаток справді потребує реагування під час введення користувачем. Ось кілька ситуацій, коли це має значення:
- Перевірка коректності поля під час його заповнення
- Надання пропозицій у стилі автодоповнення
- Швидка переробка тексту
- Підрахунок залишкових символів
- Показ чи приховування інших полів залежно від введеного
- Оцінка міцності пароля
- Пов’язування поведінки одного поля з значенням іншого
Але часто у вашому додатку немає причин цікавитися тим, що ввів користувач, поки він не натисне Submit.
У таких випадках React не потрібно відстежувати кожну літеру під час її введення.
Браузер вже виконує цю роботу.
Дозвольте браузеру цим займатися
Нативні форми HTML керують значеннями введених даних вже десятиліттями.
Ви можете скористатися цією вбудованою функціональністю за допомогою FormData:
function ContactForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget); const payload = {
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
email: formData.get('email') as string,
message: formData.get('message') as string,
}; submitData(payload);
}; return (
<form onSubmit={handleSubmit}>
<input name="firstName" required />
<input name="lastName" required />
<input name="email" type="email" required />
<textarea name="message" required /> <button type="submit">Send</button>
</form>
);
}
За допомогою цього підходу React більше не потребує окремої змінної стану для кожного поля.
Браузер зберігає те, що ввів користувач.
React просто читає ці значення після надсилання форми.
Це особливо корисно для великих форм.
Чи справді вам потрібно 30 окремих змінних стану лише для зберігання того, що хтось ввів?
Ймовірно, ні.
Контрольовані та неконтрольовані елементи введення можуть існувати разом
Усе це не означає, що контрольовані компоненти — це помилка.
Вони не є такими.
Існує багато ситуацій, де контроль над вхідними даними є цілком доцільним.
Суть у тому, щоб відмовитися від припущення, ніби React має за замовчуванням контролювати значення кожного поля.
Якщо вашому додатку не потрібні дані до моменту надсилання, часто достатньо дозволити стандартній формі їх зберігати.
Роблення такого розрізнення може значно спростити великі форми.
4. Чому булеві флаги не працюють
Ще одна постійно зустрічаючася проблема виникає у реальних кодових базах.
Розгляньмо типовий виклик API.
Поширеним підходом є відстеження цього процесу за допомогою трьох окремих булевих значень:
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
На початку всі три є хибними:
loading = false
success = false
error = false
Як тільки починається запит:
loading = true
Як тільки він успішно завершується:
loading = false
success = true
На перший погляд це здається розумним.
Але уявіть, що підрозділ обробки помилок не вдається сбросити значення isLoading.
У результаті може виникнути:
loading = true
error = true
Або недбалий механізм повторних спроб може залишити ситуацію такою:
success = true
error = true
У цей момент компонент знаходиться у стані, який логічно не має сенсу.
Чи повинен він відображати повідомлення про успіх?
Чи повинен він відображати інформацію про помилку?
Чи повинен він все ще показувати індикатор завантаження?
З технічної точки зору всі три булеві значення можуть одночасно виправдовувати відповідь „так“.
Саме ця неоднозначність і є помилкою.
Краще розглядати його як машину скінченних станів
Більш чистий підхід:
type RequestStatus =
| 'idle'
| 'loading'
| 'success'
| 'error';
const [status, setStatus] =
useState<RequestStatus>('idle');
Тепер кожен допустимий стан є чітким та взаємовиключним.
Запит може бути лише одним із наступних:
idle
loading
success
error
Немає можливості випадково показувати одночасно стани „завантаження“ та „успіх“.
Обробка також стає набагато простішою для розуміння:
if (status === 'loading') {
return <Spinner />;
}
if (status === 'error') {
return <ErrorMessage />;
}if (status === 'success') {
return <SuccessBanner />;
}return <DefaultForm />;
Це не просто зручна функція TypeScript.
Вона відображає більш загальне правило проектування:
Коли два стани ніколи не можуть бути істинними окремо один від одного, їх не слід моделювати як дві окремі змінні.
5. Уникайте використання ефектів для синхронізації стану
Це ще один випадок, коли додаткові змінні стану створюють зайвий, уникненний код.
Розгляньмо таку конфігурацію:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
У поєднанні з:
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Те, що ви тут створили, — це механізм синхронізації для значення, якому спочатку зовсім не потрібен був власний стан.
Простіше рішення:
const fullName = `${firstName} ${lastName}`;
Зміна може здаватися незначною, але її вплив на архітектуру значний.
Оригінальна версія потребує:
- Трьох окремих змінних стану
У переробленій версії потрібно лише:
- Два справжні значення
- Одне значення, отримане з них
- Жодних ефектів
- Жодної логіки синхронізації
Хорошою звичкою під час перегляду компонентів React є поставлення запитання:
"Чому це значення має існувати поза обчисленнями відображення?"
Якщо чесна відповідь — "щоб залишатися синхронізованим з чимось іншим", це зазвичай ознака того, що це значення взагалі не повинно бути станом.
6. Три запитання, які потрібно поставити перед додаванням стану
Перш ніж написати цей рядок:
const [value, setValue] = useState(...);
спочатку перевірте три умови.
Запитання 1: Чи можна обчислити це під час відображення?
Якщо дані, необхідні для формування цього значення, вже існують, немає причин зберігати його копію.
Замість того, щоб:
const [itemCount, setItemCount] = useState(0);
записувати:
const itemCount = items.length;
Замість того, щоб:
const [hasErrors, setHasErrors] = useState(false);
записувати:
const hasErrors = fields.some(field => field.error);
Уникайте зберігання стану для того, що можна отримати безпосередньо.
Питання 2: Чи має цей стан залишатися після перезавантаження сторінки?
Якщо так, подумайте, чи є URL правильним місцем для цього даних.
Типові кандидати:
- Умови пошуку
- Стан сторінки-пагінації
- Активні фільтри
- Порядок сортування
- Вибрані вкладки
- Діапазони дат
Задайте собі запитання: якщо посилання на цю сторінку буде поділене, чи має отримувач потрапити саме на той самий вигляд сторінки?
Якщо так, то URL зазвичай є кращим місцем для цих даних.
Питання 3: Чи може браузер зберігати ці дані до надсилання форми?
Якщо значення поля має значення лише під час надсилання даних, може вистачити безконтрольного введення даних.
Замість:
const [email, setEmail] = useState('');
можливо, вам просто знадобиться:
<input name="email" type="email" />
і прочитати його пізніше за допомогою:
const formData = new FormData(e.currentTarget);
Нехай браузер керує тим, що він і так може керувати.
7. Що справді належить до стану React
Жоден з цих аргументів не є підставою для повного скасування стану компонента.
Спроба повністю його прибрати була б надмірною корекцією.
Багато значень дійсно підходять для зберігання у стані.
Наприклад:
const [isOpen, setIsOpen] = useState(false);
Тимчасовий список випадання чи модальне вікно зовсім не обов’язково мають відображатися у URL.
Інтерактивний стан
const [activeStep, setActiveStep] = useState(1);
Коли значення керує взаємодією, яка залишається виключно всередині одного компонента, зберігання його у локальному стані часто є правильним рішенням.
Значення, які змінюються незалежно
Якщо щось справді має власний життєвий цикл, і немає способу обчислити його на основі вже наявних даних, тоді стан є відповідним місцем для нього.
Оптимістичний інтерфейс
Іноді потрібно відобразити щось на екрані ще до того, як сервер фактично підтвердить це.
Це також є законним використанням стану.
Мета не полягає у дотриманні цього правила:
"Використовуйте менше
useState, незалежно від обставин."
Натомість мета полягає у наступному:
"Переконайтеся, що кожен елемент стану має належного власника."
Іноді належним власником є React.
Іноді — URL.
Іноді — DOM.
Іноді — сервер.
Іноді — спеціалізований шар для отримання даних чи керування станом.
Саме це рішення щодо власності насправді формує архітектуру вашого проекту.
8. Зосередьтесь на тому, хто володіє станом, а не лише на кількості цього стану
Добре побудований додаток на React визначається не кількістю змінних стану, а іншими факторами.
Насправді важливо надати кожній частині стану чіткого, правильного власника.
Візьмемо панель керування, яка відстежує:
Search query
Filters
Pagination
Sort order
Form values
Server data
Loading status
Selected row
Modal visibility
Derived totals
Є спокуса без роздумів помістити всі ці елементи до useState.
Але ці значення виконують різні функції, тому вони не повинні знаходитися в одному місці.
Більш корисна модель розуміння полягає у їх розділенні наступним чином:
URL
- Пошук
- Фільтри
- Сторінкування
- Порядок сортування
- Вкладки для поширення
DOM
- Значення форми, які мають значення лише після її надсилання
Сервер/шар даних
- Дані клієнтів
- Відповіді API
- Дані сервера у кеші
Похідні значення
- Кількості
- Загальні суми
- Фільтровані списки
- Підсумки перевірки
Стан React
- Видимість модалу
- Тимчасові вибори
- Стан анімації
- Короткочасні взаємодії
Як тільки ви розподіляєте стан у такий спосіб, компоненту залишається синхронізувати значно менше даних.
Саме це скорочення є джерелом справжньої користі.
9. Дубльований стан — джерело помилок майбутнього
Найбільш ризикованим станом у додатку не обов’язково є найскладніший стан.
Часто це стан, який існує в кількох місцях одночасно.
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
Тепер ви несете відповідальність за те, щоб filteredProducts завжди збігався з тим, що є в products.
Якщо додати ще один фільтр, отримаємо:
const [filteredProducts, setFilteredProducts] = useState([]);
const [searchResults, setSearchResults] = useState([]);
Тепер з’являється додаткова залежність, яку потрібно вручну підтримувати у згоді.
Рано чи пізно хтось оновлює основні дані, але забуває оновити одну з похідних змінних, яка від них залежить.
Інтерфейс поступово втрачає синхронізацію з реальністю.
Користувач зрештою повідомляє щось на кшталт:
"Кількість вказує 42 продукти, але на екрані відображається лише 39."
Ця різниця зазвичай не є помилкою в самій логіці фільтрації.
Це симптом того, що у додатку є більше одного джерела інформації для одних і тих самих даних.
Якщо ж значення отримувати іншим чином:
const filteredProducts = products.filter(...);
Ви усуваєте цілу категорію помилок ще до того, як вони можуть виникнути.
Ось чому скорочення кількості стану — це не лише питання продуктивності відображення.
Насправді йдеться про зменшення кількості елементів, які можуть вийти з синхронності.
10. Витрати на стан виходять за межі повторного відображення
Коли люди обговорюють стан, розмова зазвичай центрується навколо продуктивності: „Чи спричинить це ще одне відображення?“
Це закономірна турбота, але це не найдорожча частина проблеми.
Справжніми витратами є складність.
Кожна додаткова змінна стану — це ще одна річ, яку ваша команда мусить пам’ятати.
Потім неминуче додається виклик функції-встановлювача.
setSomething(...)
Потім, ймовірно, useEffect, щоб відреагувати на це.
useEffect(...)
Потім масив залежностей, який перелічує значення, які він стежить.
[firstValue, secondValue]
Потім якась логіка скидання, щоб повернути його до початкового значення.
setSomething(initialValue)
Потім крайній випадок, коли цей стан має залишатися узгодженим із зміною навігації.
Потім звіт про баг, оскільки стан не очищується після певної зміни.
Поступово кодова база перетворюється на мережу логіки синхронізації.
Це значно складніше для розуміння, ніж компонент, чиї дані надходять чисто з кількох добре визначених джерел.
Справжня навичка в React — це не керування більшою кількістю станів
Це розуміння того, де має знаходитися кожен елемент стану.
Менш досвідчений розробник, який дивиться на інтерфейс користувача, може запитати:
"Який стан потрібен тут?"
Краща архітектурна інтуїція призводить до іншого запитання:
«Які з цих значень дійсно потребують бути частиною стану?»
А найточніша версія цього запитання звучить так:
«Хто є законним власником кожної частини стану?»
Якщо змінити підхід, це може повністю змінити спосіб створення додатків на React.
URL може відповідати за стан навігації.
Браузер може відповідати за стан форм.
Сервер може відповідати за дані, якими володіє сервер.
Похідні значення можуть залишатися похідними, замість того щоб копіюватися.
Об’єднання типів у TypeScript можуть моделювати стани, які є взаємовиключними.
А сам React може зосередитися на тимчасовому, інтерактивному стані, який справді належить до шару користувацького інтерфейсу.
Такий розподіл обов’язків створює набагато більш чисту архітектуру.
Підсумок
Наявність більшої кількості станів сама по собі не є досягненням.
Більша кількість змінних стану не робить додаток більш здатним.
Зазвичай це означає більше роботи з синхронізацією, більше залежностей, які потрібно відстежувати, та більше шансів на те, що дані стануть неузгодженими.
Перш ніж використовувати ще один useState, розгляньте ці запитання:
- Чи можна обчислити це значення під час відображення? Якщо так, отримайте його шляхом обчислень, а не зберігайте окремо.
- Чи потрібно, щоб воно збереглося після оновлення сторінки або могло бути поширене через посилання? Якщо так, додайте його до параметрів пошуку URL.
- Чи потрібно відстежувати це поле форми при кожному натисканні клавіші? Якщо ні, може вистачити неконтрольованого введення у поєднанні з
FormData.
Метою ніколи не було мінімізувати кількість викликів useState.
Метою є забезпечення того, щоб якомога менше значень відповідали за визначення логіки вашого додатку.
Адже кожен зайвий елемент стану — це ще одна річ, яку додаток мусить синхронізувати.
А кожен крок синхронізації, який ви уникаєте, — це баг, який вам ніколи не доведеться виправляти.