Головна / Статті

Компоненти React мають бути чистими: ідея застосування StrictMode та компілятора

Чому React очікує чисті компоненти, як подвійне відтворення у StrictMode виявляє нечистоту, де мають знаходитися побічні ефекти, та чому компілятор React покладається на чистоту.

Видавець: REACTAPP.TOP9 хв читання2023 слів

Джерела: React Components Must Be Pure — the Secret Behind StrictMode and the Compiler

До цього часу історія React полягала у тому, що «напишіть код так, і екран з’явиться». Однак існує одне правило, якого React тихо очікує від кожного компонента, і воно пояснює дві речі, які заплутують більшість початківців: чому StrictMode викликає ваш компонент двічі та як компілятор React може настільки ефективно оптимізувати код. Це правило — чистота.

Що таке чиста функція

Згадайте математику: y = f(x) = 2x повертає 6 для вхідних даних 3. Він робить це вчора, сьогодні та під час сотого виклику, і ніколи не намагається змінити щось поза межами самого себе.

Така функція є чистою, що означає, що дотримуються дві умови.

  1. Однакові вхідні дані завжди дають однаковий результат.
  2. У неї немає побічних ефектів, тому ніщо поза межами її власного діапазону не змінюється.

Код робить це зрозумілішим. double нижче є чистим, тоді як addToTotal — ні.

// ✅ pure function — same input, same output, and it touches nothing outside
function double(x: number): number {
  return x * 2;
}
double(3); // 6
double(3); // 6 — six no matter how many times you call it

// 🔴 impure function — it changes the outer variable `total` (a side effect)
let total = 0;
function addToTotal(x: number): number {
  total += x;     // side effect!
  return total;   // the result changes on every call
}
addToTotal(3); // 3
addToTotal(3); // 6 — same input, different result

Компоненти також мають бути чистими

React припускає, що кожен компонент є чистою функцією. З однаковими параметрами та станом він повинен щоразу повертати однаковий JSX.

type Props = { name: string };

// ✅ pure — same name, always the same result
function Greeting({ name }: Props) {
  return <h1>Hello, {name}!</h1>;
}

Якщо надати цьому компоненту ім’я „Jane“, ви завжди отримаєте <h1>Hello, Jane!</h1>. React покладається на цю передбачуваність для ефективного оновлення екрана.

Порушення правила: зміни зовнішнього контексту під час відображення

Проблеми виникають, коли компонент змінює щось поза собою під час свого відображення.

let count = 0; // a variable outside the component

// 🔴 not pure — changes an external variable while rendering
function Counter() {
  count = count + 1; // side effect!
  return <p>{count}</p>;
}

Counter збільшує значення зовнішньої змінної count під час кожного відображення, тому його результат залежить від того, як часто його викликають: 1 — вперше, 2 — вдруге. Обидві умови чистоти не дотримуються. React може відображати компонент кілька разів або в іншому порядку, тому такий компонент створює проблеми, які важко передбачити.

Спробуйте та подивіться, чому ви отримуєте 2

Якщо запустити цей Counter, першим, що з’явиться на екрані, буде 2, а не 1, і це значення не зростатиме далі. Спочатку це здається дивним, але розуміння цього явища є чудовим прикладом для пояснення концепції чистоти коду.

Чому 2? Під час розробки режим StrictMode викликає функцію компонента двічі під час його монтування, тому одне монтування виконує Counter двічі.

  • Перший виклик: count змінюється з 0 на 1, і результатом є <p>1</p>.
  • Другий виклик: count змінюється з 1 на 2, і результатом є <p>2</p>.

На екрані відображається другий результат. Коли StrictMode вимкнено, функція виконується один раз, і ви побачите 1.

Чому вона зупиняється на 2, а не продовжує зростати до 3, 4 тощо? Компонент виконується лише тоді, коли React його перерисовує. Цей компонент не має стану, а його батьківський елемент не перерисовується, тому він виконується під час початкового завантаження (двічі, завдяки StrictMode), а потім припиняє роботу, залишаючи count на тому ж рівні.

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

import { useState } from 'react';

let count = 0;

function Counter() {
  const [, setTick] = useState(0); // a device to trigger re-renders
  count = count + 1; // 🔴 changes an external variable on every render
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setTick((n) => n + 1)}>Re-render</button>
    </div>
  );
}

Кожна натискання заново відрендерує Counter через зміну його власного стану, а StrictMode викликає цей процес двічі щоразу, тому число зростає на два: 2, 4, 6 тощо. Без StrictMode воно зростало б на один за раз.

Одне застереження щодо цього демо. Навіщо взагалі розміщувати стан всередині Counter? Якщо перемістити його до батьківського елемента та залишити Counter як дочірній без пропсів, натискання кнопки не змінить відображуване число 2. Компонент, який не отримує жодних вхідних даних, — це той, чий результат ніколи не може змінитися, тому React Compiler кешує його та пропускає повторний рендеринг. Щоб побачити цю зміну, потрібно вплинути на зовнішню змінну всередині компонента, який насправді здійснює повторний рендеринг. Дивно, але саме ця поведінка є гарною ілюстрацією того, що компілятор припускає чистоту коду. Те, як компілятор кешує компоненти, заслуговує на окрему дискусію.

Ось суть проблеми. Чистий компонент має повертати однаковий результат незалежно від кількості викликів, але цей компонент залежить від кількості викликів. StrictMode навмисно викликає його двічі, щоб показати вам цю недосконалість.

Рішення полягає у тому, щоб не втручатися ззовні. Під час відображення обчислюйте результат лише на основі власних параметрів та стану.

// ✅ pure — computed from input (props) alone
function Counter({ count }: { count: number }) {
  return <p>{count}</p>;
}

Ви можете змінити те, що створили під час рендерингу

Чистота не означає нічого не змінювати. Це означає не торкатися того, що існувало до початку рендерингу, такого як зовнішні змінні, параметри та стан. Змінні чи масиви, які ви створюєте безпосередньо під час рендерингу, належать вам для модифікації. Це просто обчислення, а не побічний ефект.

function ProductList({ products }: { products: Product[] }) {
  // ✅ this array was just created in this render — handle it however you like
  const sorted = [...products].sort((a, b) => a.price - b.price);
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Важливим є копіювання даних за допомогою [...products]. Сортування оригінального масиву products, який надходить як параметри, призведе до зміни цих параметрів та порушення правила. Копія була створена під час цього відображення, тому все гаразд.

Перша загадка: чому StrictMode виконує код двічі

Під час розробки StrictMode навмисно викликає функції компонентів двічі, щоб виявити компоненти, які не є чистими. Це той самий обгорток <StrictMode>, який був доданий навколо застосунку під час налаштування проекту.

// main.tsx — in development, the components inside this render twice
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Чистий компонент дає ідентичні результати під час обох виконань, тому нічого не відбувається. Компонент на кшталт Counter вище, який впливає на зовнішню змінну, одразу показує ознаки проблем: результати змінюються або значення подвоюється. StrictMode виявляє ці симптоми ще під час розробки, задовго до того, як вони перетворяться на складні проблеми, які важко виправити після запуску додатку.

Отже, подвійне виконання — це не баг. Це перевірка стану. Якщо двічі запустити компонент не створює проблем, це означає, що він є чистим. У продакшн-версії він виконується лише один раз.

Де мають бути побічні ефекти

Ви можете поставити логічне запитання: логування, надсилання даних на сервер та запуск таймера явно необхідні, тож де вони мають знаходитися? Будь-де, крім самого процесу відображення.

  • Реакція на дії користувача, такі як кліки та введення тексту, має відбуватися у обробниках подій.
  • Синхронізація з зовнішньою системою після намальовування екрана має знаходитися у розділі Ефекти.

Припустимо, ви фіксуєте перегляд сторінки безпосередньо в тілі відтворення: цей код виконуватиметься з кожним відтворенням, а під режимом StrictMode під час розробки його виконання подвоюватиметься. Перемістіть цей виклик у обробник подій, і він відбуватиметься лише тоді, коли хтось справді натисне кнопку.

// 🔴 side effect during rendering — fires on every render
function ProductPage({ id }: { id: number }) {
  logView(id); // a side effect in the render body — not allowed
  return <h1>Product {id}</h1>;
}

// ✅ in an event handler — runs only at the moment the user clicks
function BuyButton({ id }: { id: number }) {
  return <button onClick={() => logPurchase(id)}>Buy</button>;
}

Зберігайте тіло обробки чистим та переміщуйте побічні ефекти у відповідні місця. Цей принцип постійно повторюється.

Друга загадка: чому компілятор залежить від чистоти

Якщо ви увімкнули React Compiler, ви, можливо, пам’ятаєте, що він бере на себе функції, які раніше реалізувалися вручну за допомогою useMemo та useCallback. Саме чистота дозволяє це зробити.

Компілятор працює за припущенням, що компоненти є чистими. Оскільки однаковий вхід гарантовано дає однаковий вихід, він вважає безпечним повторно використовувати попередній результат, якщо вхід не змінився, та автоматично впроваджує мемоізацію. Якщо компонент змінив щось під час обробки, повторне використання може призвести до неправильних результатів, тому компілятор пропускає оптимізацію для компонентів, які порушують це правило. Правило ESLint, увімкнене разом із компілятором, показує, який саме компонент є причиною проблеми.

// 🔴 not pure → the compiler skips optimization (ESLint points at this component)
let renderCount = 0;
function RenderCounter() {
  renderCount++; // mutating an external variable during render — a violation
  return <p>{renderCount}</p>;
}

// ✅ pure → the compiler memoizes automatically (reuse the previous result when inputs match)
function Label({ text }: { text: string }) {
  return <p>{text}</p>;
}

function App() {
  return (
    <>
      <RenderCounter />
      <Label text="Pure Component" />
    </>
  );
}

export default App;

Компонент, написаний у чистому вигляді, відображається в DevTools із позначкою „Memo“, навіть якщо не було використано useMemo.

Коротко кажучи, чистота коду — це передумова для того, щоб компілятор міг вам допомогти. Пишіть чисті компоненти, і покращення продуктивності настануть автоматично. Єдине, про що варто турбуватися, — це написання чистого коду.

Бачення інтерфейсу як дерева

Ще один погляд допомагає повністю зрозуміти ситуацію. Компоненти містять інші компоненти: App містить ProductList, який, у свою чергу, містить кілька ProductCard. Це вкладення природним чином утворює дерево.

function App() {
  return <ProductList products={products} />;
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </ul>
  );
}

Оскільки компоненти вкладаються один у одного, цей код створює наступну структуру дерева.

App
└─ ProductList
   ├─ ProductCard
   ├─ ProductCard
   └─ ProductCard

Чим далі ви просуваєтесь, тим більше значення набуває деревоподібний формат. Дані у вигляді параметрів проходять по ньому зверху вниз, а щодо стану, про який ви дізнаєтесь пізніше, вибір місця його розміщення в дереві стає ключовим елементом вашого дизайну. Пам’ятайте про принцип «екран = дерево компонентів», і навіть складні інтерфейси можна структурно проаналізувати.

Підсумок

  • React розглядає кожен компонент як чисту функцію: однакові параметри та стан, однаковий JSX.
  • Під час відображення не змінюйте нічого зовнішнього, включаючи змінні, параметри та стан. Значення, створені під час поточного відображення, можна змінювати, наприклад під час сортування даних.
  • Двократне виконання коду в режимі StrictMode під час розробки є перевіркою на наявність нечистих компонентів; чистий компонент проходить перевірку без змін.
  • Ефекти другого порядку слід розміщувати у обробниках подій або у функціях Effects, а не в тілі функції відображення.
  • Чистота коду — це те, що дозволяє компілятору React автоматично оптимізувати його, тож написання чистого коду забезпечує кращу продуктивність.
  • Інтерфейс користувача — це дерево компонентів, і дані проходять через нього.

Поки що екран є «замороженим» зображенням: ви можете скласти його з компонентів, писати маркап за допомогою JSX, передавати дані через атрибути props та створювати динамічні відображення за допомогою умов та циклів, але натискання кнопки нічого не призводить. Наступним кроком є змусити екран реагувати на користувача, і це починається з обробки подій.

Офіційні сторінки документації React про збереження чистоти компонентів та про розуміння інтерфейсу користувача як дерева детальніше розглядають ці теми.