nmk

Лекція №6 (2 години). Хук useState: Робота з локальним станом на глибинному рівні

План лекції

  1. Парадигма хуків: Архітектура React Fiber та зв’язні списки (Linked Lists).
  2. Правила хуків (Rules of Hooks): Чому заборонені умови та цикли.
  3. Внутрішня механіка useState: черги оновлень та асинхронна природа.
  4. Функціональні оновлення стану (Updater Functions).
  5. Пакетне оновлення (Automatic Batching) в React 18: Конкурентний рендеринг.
  6. Ледача ініціалізація (Lazy Initialization) та керування пам’яттю.
  7. Антипатерни стану: похідний стан (Derived State) та дублювання даних.

Перелік умовних скорочень

Вступ

Поява хуків (Hooks) у React 16.8 стала революцією, що раз і назавжди змінила підхід до написання компонентів. Хук useState здається неймовірно простим на перший погляд, проте його невірно сприймають як просто “змінну, що змушує екран оновлюватись”. Для інженерів 4-го курсу важливо розуміти, що стан у React — це не миттєвий показник, а план (запит) на рендер у майбутньому. Незнання механік черг оновлень (Update Queues) та пакетування (Batching) є найчастішою причиною багів у складних асинхронних інтерфейсах (таких як гонки даних — Race Conditions). Ця лекція присвячена правильному інженерному управлінню локальним станом компонента.


1. Парадигма хуків: Архітектура React Fiber та зв’язні списки (Linked Lists)

До появи хуків перевикористання логіки роботи зі станом досягалося через патерни HOC (Higher-Order Components) та Render Props, що призводило до “Пекла обгорток” (Wrapper Hell) — нескінченної вкладеності компонентів у дереві React Developer Tools.

Хуки вирішили цю проблему, дозволивши використовувати власні (custom) функції use.... Але як звичайна JavaScript-функція “пам’ятає” дані між рендерами, якщо всі її локальні змінні знищуються після виконання return?

Секрет у React Fiber: На стадії рендерингу (у пам’яті), кожному компоненту відповідає об’єкт Fiber Node. Всередині Fiber Node є поле memoizedState. Хуки у React реалізовані як структура даних однобічно зв’язного списку (Singly Linked List).

Коли ви викликаєте підряд 3 хуки:

const [name, setName] = useState("Alex"); // Хук 1
const [age, setAge] = useState(20); // Хук 2
useEffect(() => {}, []); // Хук 3

React будує під капотом ланцюжок: Hook1 -> Hook2 -> Hook3. Наступного разу, коли компонент відрендериться знову, React пройдеться по цьому тому ж самому ланцюжку в тому ж самому порядку, відновивши збережені значення для name та age.


2. Правила хуків (Rules of Hooks): Чому заборонені умови та цикли

Оскільки React покладається виключно на порядок виклику хуків під час обходу зв’язного списку (Linked List), виклик хука всередині блоку if або for зруйнує всю архітектуру.

Фатальна помилка (Антипатерн):

const UserProfile = ({ isLoggedIn }) => {
  if (isLoggedIn) {
    const [lastLogin, setLastLogin] = useState(Date.now()); // ПОМИЛКА!
  }
  const [theme, setTheme] = useState("dark");

  return <div>{theme}</div>;
};

Якщо під час другого рендеру isLoggedIn стане false, перший хук не викличеться. React спробує дістати другий елемент зв’язного списку (де зберігалася тема 'dark') і помилково підставить його у setLastLogin, повністю зламавши логіку додатка.

Два золоті правила хуків:

  1. Викликайте хуки ТІЛЬКИ на верхньому рівні (Top Level) компонента (до будь-яких ранніх повернень return).
  2. Викликайте хуки ТІЛЬКИ з функціональних React-компонентів або з власних (Custom) хуків. Ніколи зі звичайних JavaScript-функцій.

Контроль: Забезпечується плагіном лінтера eslint-plugin-react-hooks, який аналізує AST дерева вашого коду на етапі написання.


3. Внутрішня механіка useState: черги оновлень та асинхронна природа

Синтаксис хука використовує деструктуризацію масиву: const [state, setState] = useState(initialValue).

Найбільша омана новачків: Виклик setState не змінює стан одразу (синхронно) в поточному коді. Функція setState лише додає об’єкт-запит (Update Object) до черги оновлень (Update Queue) компонента і повідомляє React: “Заплануй рендер цього компонента наново”.

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1); // setCount(0 + 1) -> Заплановано рахунок 1
    setCount(count + 1); // setCount(0 + 1) -> Заплановано рахунок 1
    setCount(count + 1); // setCount(0 + 1) -> Заплановано рахунок 1

    // Тут count ВСЕ ЩЕ дорівнює 0!
    console.log(count); // Виведе 0, а не 3
  };

  // Після завершення цієї функції, React робить ОДИН рендер і count стає = 1.
};

Змінна count у функціональному компоненті є константою (const). Вона зафіксована для поточного рендеру (closure / замикання). Оновлення стану викликає нове виконання всієї функції-компонента з вже новою зафіксованою const count = 1.


4. Функціональні оновлення стану (Updater Functions)

Що робити, якщо нам дійсно потрібно оновити стан тричі підряд, залежачи від попереднього результату? Або якщо функція setCount викликається всередині setTimeout або setInterval, які дивляться на застаріле замикання (stale closure)?

Для цього використовується патерн передачі функції в setState замість жорсткого значення.

const BetterCounter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // React покладе ці три функції в чергу (Queue)
    setCount((prevCount) => prevCount + 1); // React виконає: 0 + 1 (результат 1)
    setCount((prevCount) => prevCount + 1); // React виконає: 1 + 1 (результат 2)
    setCount((prevCount) => prevCount + 1); // React виконає: 2 + 1 (результат 3)
  };

  // Компонент перерендериться один раз і count дорівнюватиме 3.
};

Інженерне правило: Якщо наступний стан обчислюється на основі поточного стану — ЗАВЖДИ використовуйте функціональне оновлення.


5. Пакетне оновлення (Automatic Batching) в React 18: Конкурентний рендеринг

В React 17 та нижче існувала проблема продуктивності під час асинхронних операцій (fetch, setTimeout). Якщо ви викликали кілька оновлень стану всередині .then() після fetch-запиту, React робив окремий рендер для кожного setState.

React 18 впровадив Automatic Batching (Автоматичне пакетування). Тепер React групує УСІ виклики setState у межах одного макро/мікрозавдання (Event Loop) і викликає рендер рівно 1 раз.

const fetchUser = async () => {
  setIsLoading(true);
  const data = await api.getUser(); // пауза

  // React 18: Обидва ці оновлення згрупуються (Batching)
  setUser(data.user);
  setIsLoading(false);
  // React зробить ОДИН рендер компонента замість двох.
};

Якщо винятково потрібно форсувати негайний рендеринг (щоб прочитати розміри ДОМ елемента після зміни) використовують імперативний метод flushSync з react-dom, проте це є архітектурним виключенням і використовується рідко.


6. Ледача ініціалізація (Lazy Initialization) та керування пам’яттю

Аргумент, переданий у useState, використовується виключно під час першого рендеру (ініціалізації) компонента. У всіх наступних рендерах це значення просто ігнорується рушієм.

Якщо ініціалізація вимагає важкої операції (наприклад, парсинг гігантського масиву з localStorage або обчислення фібоначчі), використання звичайного підходу “вб’є” процесор:

// ПОГАНО: getHeavyData() виконується при КОЖНОМУ кліці і зміні стану, хоча React відкине результат після першого разу!
const [data, setData] = useState(getHeavyData());

Щоб вирішити цю проблему без використання useEffect, застосовують Lazy Initialization (Ледачу ініціалізацію) — передачу функції без її безпосереднього виклику:

// ДОБРЕ: React викличе getHeavyData лише один раз при першому монтуванні компонента.
const [data, setData] = useState(() => getHeavyData());

7. Антипатерни стану: похідний стан (Derived State) та дублювання даних

Найбільша архітектурна помилка, яку допускають розробники — це збереження значення в useState, яке можна легко обчислити математично під час рендеру. Це призводить до несинхронізованих багів (Out of Sync).

Антипатерн (Дублювання):

const Cart = () => {
  const [items, setItems] = useState([{ price: 10 }, { price: 20 }]);
  // ПОМИЛКА: Це похідний стан. Його НЕ ТРЕБА зберігати в пам'яті через useState.
  const [total, setTotal] = useState(30);

  const addItem = (item) => {
    setItems([...items, item]);
    // Легко забути оновити total, або зробити помилку в обчисленнях
    setTotal(total + item.price);
  };
};

Інженерно правильне рішення (Derived State обчислюється “на льоту”):

const Cart = () => {
  const [items, setItems] = useState([{ price: 10 }, { price: 20 }]);

  // ТА-ДАМ! Ми просто обчислюємо це при кожному рендері.
  // Джерело істини (Single Source of Truth) залишається ОДНЕ — це масив items!
  const total = items.reduce((sum, item) => sum + item.price, 0);

  // Для ВЕЛИКИХ масивів можна обгорнути обчислення в хук оптимізації useMemo.
  // const total = useMemo(() => items.reduce(...), [items]);
};

Створення похідного стану в useState чи useEffect — це марне споживання оперативної пам’яті та зайві цикли рендерингу. Розраховуйте все, що можете розрахувати, безпосередньо в тілі компонента.


Висновки

  1. useState в React працює не як проста змінна, а як диспетчер черг оновлень, який вписує запити на новий стан у структуру даних Singly Linked List в об’єкті Fiber.
  2. Неухильне дотримання Rules of Hooks гарантує правильний порядок обходу зв’язного списку пам’яті: ніяких хуків в умовах, циклах (for, map) або ранніх поверненнях (return).
  3. Асинхронна (пакетна) природа оновлення стану робить використання замикань (closures) небезпечним при множинних оновленнях. Саме тому для безпечного розрахунку наступного стану на базі поточного застосовують Updater Functions (setState(prev => prev + 1)).
  4. Automatic Batching у React 18 радикально покращує продуктивність, групуючи мережеві та таймаут-операції в один цикл перемальовки екрана.
  5. Розробник має уникати збереження у стані того, що може бути легко вирахувано на льоту (Derived State) — це захищає архітектуру від “розсинхронізації” даних на екрані.

Джерела

  1. Офіційна документація React. “State: A Component’s Memory”. URL: https://react.dev/learn/state-a-components-memory
  2. React Docs. “Queueing a Series of State Updates”. URL: https://react.dev/learn/queueing-a-series-of-state-updates
  3. React Docs. “Choosing the State Structure”. URL: https://react.dev/learn/choosing-the-state-structure
  4. “React 18: Automatic Batching”. React Blog. URL: https://react.dev/blog/2022/03/29/react-v18#new-feature-automatic-batching
  5. “Rules of Hooks”. React Legacy Docs. URL: https://legacy.reactjs.org/docs/hooks-rules.html
  6. Kent C. Dodds. “Application State Management with React”. URL: https://kentcdodds.com/blog/application-state-management-with-react
  7. “Under the hood of React’s hooks system”. E.Y. Blog. URL: https://medium.com/the-guild/under-the-hood-of-reacts-hooks-system-eb59638c9dba
  8. “Derived State in React”. React Training. URL: https://reacttraining.com/blog/derived-state
  9. “How does React internally implement Hooks?”. E. Yang.
  10. MDN: Event Loop and Promises. URL: https://developer.mozilla.org/

Запитання для самоперевірки

  1. Чому React фізично забороняє та скидає помилку, якщо розробник помістив виклик хука useState всередину простої умови if або викликав після слова return?
  2. З точки зору архітектури Event Loop та процесів React 18 “Automatic Batching”, чому три підряд виклики setCount(0 + 1) створять лише один рендер із кінцевим результатом 1, а не 3?
  3. В якій ситуації і з якою метою інженер зобов’язаний застосовувати патерн ледачої ініціалізації (Lazy Initialization), передаючи функцію useState(() => init())?
  4. Що таке Derived State (Похідний стан) і чому його збереження через useState вважається критичною архітектурною помилкою?
  5. Що відбуватиметься “під капотом” React Fiber, якщо всередині обробника кліку ми використаємо синтаксис Updater Function (напр., setCount(prev => prev + 1)) кілька разів підряд?