nmk

Лекція №5 (2 години). Функціональні компоненти та пропси: Архітектура та строга типізація

План лекції

  1. Еволюція компонентної моделі: чому індустрія відмовилася від класів (OOP) на користь функцій.
  2. Математична основа React: Чисті функції (Pure Functions), детермінованість та референсна прозорість.
  3. Пропси (Props) як імутабельний контракт компонента: механізми Memory Allocation та Shallow Comparison.
  4. Односпрямований потік даних (One-Way Data Flow) та концепція підняття стану (Lifting State Up).
  5. Патерни композиції: Inversion of Control через children та Render Props.
  6. Інженерія типізації: використання TypeScript для жорсткого контролю інтерфейсів компонентів.
  7. Оптимізація потоку передачі пропсів: React.memo та проблема нестабільних посилань (Referential Equality).

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

Вступ

Для розробників рівня Senior/Middle+, розуміння компонентів React виходить далеко за межі синтаксису “функція повертає JSX”. На масштабах ентерпрайз-додатків компонент стає фундаментальною одиницею архітектури. Нерозуміння принципів імутабельності (Immutability), відмінності між передачею за посиланням (by reference) та за значенням (by value), а також побічних ефектів (Side Effects) неминуче призводить до серйозних проблем із продуктивністю (zombie renders, memory leaks) та архітектурної зв’язності (tight coupling). Ця лекція фокусується на глибинних механізмах функціональних компонентів, патернах їх композиції та застосуванні строгих контрактів за допомогою TypeScript.

1. Еволюція компонентної моделі: відмова від класів на користь функцій

До версії React 16.8 (2019 рік) індустрія ділила компоненти на “розумні” (Smart/Class) та “дурні” (Dumb/Functional). Класи використовувалися там, де був потрібен стан або методи життєвого циклу (componentDidMount). Проте класи принесли три системні проблеми:

  1. Складність контексту this: Непередбачувана втрата контексту при передачі методів класу як callback-функцій вимагала постійного .bind(this) або використання стрілочних функцій, що генерували нові посилання при кожному рендері.
  2. Фрагментація логіки: Життєвий цикл класу змушував розробників розбивати єдину бізнес-логіку (наприклад, підписку на WebSocket та відписку від нього) між методами componentDidMount та componentWillUnmount.
  3. Проблеми мініфікації та AOT-компіляції: Класи в JavaScript (які насправді є синтаксичним цукром над прототипами) дуже важко аналізувати статичним аналізаторам та мініфікаторам (як-от Terser), на відміну від простих функцій.

Перехід на функціональні компоненти з Хуками (Hooks) дозволив застосувати підходи функціонального програмування: відокремлення даних від поведінки та можливість композиції логіки (Custom Hooks).

2. Математична основа React: Чисті функції (Pure Functions)

React покладається на те, що кожен функціональний компонент є чистою функцією відносно своїх пропсів.

У комп’ютерних науках чиста функція має дві властивості:

  1. Детермінованість: Для однакового набору вхідних даних (аргументів) функція завжди повертає однаковий результат.
  2. Відсутність побічних ефектів (Side Effects): Функція не мутує зовнішній стан (не змінює глобальні змінні, не перезаписує аргументи, не виконує непередбачувані I/O операції під час рендеру).

Порушення чистоти (Мутація пропсів - АНТИПАТЕРН):

const BadProfile = ({ user }) => {
  // КАТЕГОРИЧНО ЗАБОРОНЕНО: мутація об'єкта, що прийшов за посиланням
  user.lastLogin = new Date(); // Side Effect!

  return (
    <div>
      {user.name} logged at {user.lastLogin.toString()}
    </div>
  );
};

React у Strict Mode навмисно викликає функції-компоненти двічі у середовищі розробки, щоб виявити такі приховані мутації. Архітектурне правило: Рендеринг має бути чистою операцією обчислення дерева UI. Будь-які мутації або запити в мережу повинні бути ізольовані (через обробники подій або useEffect).

3. Пропси (Props) як імутабельний контракт компонента

props (скорочення від properties) — це JavaScript об’єкт, який передається від батьківського компонента до дочірнього. Головна архітектурна аксіома: Props є read-only (лише для читання).

Механізм передачі (Memory Allocation)

Коли ми пишемо JSX: <UserCard name="Alex" config= onClick={handleClick} />, компілятор збирає всі атрибути в один об’єкт. Важливо розуміти, що при кожному рендері батьківського компонента створюється новий об’єкт props з новим посиланням у пам’яті.

Але значення всередині цього об’єкта передаються згідно з правилами JS:

Це означає, що створення інлайн-об’єкта config= гарантовано створює нове посилання в купі (Heap) при кожному рендері, навіть якщо розробнику здається, що вміст не змінився. Це прямо впливає на оптимізацію компонента.

4. Односпрямований потік даних та підняття стану

Архітектура React базується на One-Way Data Flow (Односпрямованому потоці даних), що спускається зверху вниз по дереву компонентів. Дочірній компонент не має права і технічної можливості напряму змінити пропси, які йому передали. Якщо “дитина” потребує зміни даних, використовується патерн Lifting State Up (Підняття стану): дитині передається callback-функція, яку вона викликає, щоб повідомити “батька” про подію.

// Інженерний грамотний дизайн: компонент не знає ПРО бізнес-логіку, він лише диспетчеризує події
interface TodoItemProps {
  id: string;
  title: string;
  isCompleted: boolean;
  onToggle: (id: string, nextStatus: boolean) => void; // Контракт зворотної дії
}

const TodoItem = ({ id, title, isCompleted, onToggle }: TodoItemProps) => (
  <div onClick={() => onToggle(id, !isCompleted)}>
    {title} - {isCompleted ? "Done" : "Pending"}
  </div>
);

Цей підхід робить компоненти надзвичайно передбачуваними, оскільки джерело істини (Single Source of Truth) знаходиться виключно нагорі, а не розмазане по всьому додатку (як це було при двоспрямованому зв’язуванні, Two-Way Binding, в AngularJS).

5. Патерни композиції: Inversion of Control через children та Render Props

У складних ентерпрайз-додатках жорстке прокидання пропсів через багато рівнів (Prop Drilling) призводить до сильного зв’язування (Tight Coupling). Системна інженерія вирішує це через патерни композиції.

Патерн children (Вкладеність як абстракція)

Властивість props.children дозволяє компоненту виступати в ролі “контейнера”, не знаючи наперед, який контент у нього передадуть.

// Modal.tsx - контейнер знає ЛИШЕ про візуальне оформлення вікна
const Modal = ({
  children,
  onClose,
}: {
  children: React.ReactNode;
  onClose: () => void;
}) => {
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
};

// App.tsx
<Modal onClose={close}>
  <ComplexBillingForm /> {/* Modal нічого не знає про BillingForm. Це IoC. */}
</Modal>;

Патерн Render Props

Якщо “дитина” повинна повідомити якісь дані “нагору” безпосередньо в момент побудови JSX, використовується патерн Render Props (проп-функція, що повертає JSX).

<MouseTracker
  render={({ x, y }) => (
    <h1>
      Курсор знаходиться на: {x}, {y}
    </h1>
  )}
/>

6. Інженерія типізації: TypeScript у компонентах

На рівні Senior розробки JavaScript без типізації для великих проектів вважається неприпустимим. TypeScript забезпечує статичну перевірку контрактів (Props) ще на етапі написання (Compile-time), що ліквідує цілий клас помилок (ReferenceError, TypeError).

Типовий ентерпрайз патерн типізації:

import type { ComponentPropsWithoutRef, ReactNode } from "react";

// Наслідуємо ВСІ нативні атрибути кнопки (type, disabled, aria-label, onClick)
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
  variant?: "primary" | "secondary" | "danger";
  isLoading?: boolean;
  leftIcon?: ReactNode; // Типізація для будь-якого React-вмісту (JSX, рядки, фрагменти)
}

// Деструктуризація пропсів + збір решти (...rest) для делегування в DOM
export const Button = ({
  variant = "primary",
  isLoading,
  leftIcon,
  children,
  className,
  ...rest
}: ButtonProps) => {
  return (
    <button
      className={`btn-${variant} ${className || ""}`}
      disabled={isLoading || rest.disabled}
      {...rest} // Прокидаємо всі інші нативні пропси до реального DOM елементу
    >
      {isLoading ? <Spinner /> : leftIcon}
      {children}
    </button>
  );
};

Використання ComponentPropsWithoutRef<'button'> звільняє розробника від ручного написання інтерфейсів для подій миші, клавіатури, tabindex тощо. Прокидання ...rest гарантує, що кастомна кнопка підтримуватиме всі ті самі атрибути, що й нативна HTML-кнопка (a11y атрибути, data-атрибути).

7. Оптимізація потоку передачі: React.memo та Referential Equality

За замовчуванням, якщо батьківський компонент рендериться, React рекурсивно рендерить всі його дочірні компоненти, незалежно від того, змінилися їхні пропси чи ні. На великих деревах це величезна трата ресурсів процесора.

Для оптимізації використовують HOC (High-Order Component) React.memo(). Він здійснює Shallow Comparison (поверхневе порівняння через Object.is()) старих і нових пропсів. Якщо вони ідентичні — компонент не перераховується.

Проблема глибокого порівняння (Referential Equality):

const Parent = () => {
  // Кожен рендер створює НОВЕ місце в пам'яті для об'єкта config та функції onAction
  const config = { theme: "dark" };
  const handleAction = () => console.log("Action");

  // React.memo для <Child /> БУДЕ ЗЛАМАНО. Object.is(oldConfig, newConfig) === false
  return <MemoizedChild config={config} onAction={handleAction} />;
};

Для вирішення цього, розробники зобов’язані стабілізувати посилання (Referential Stability) за допомогою хуків мемоїзації: useMemo (для обчислень та об’єктів) та useCallback (для функцій). Без цього React.memo стає “мертвим кодом”, що виконує марні порівняння і все одно спричиняє ререндер.

Висновки

  1. Перехід до функціональних компонентів є не просто зміною синтаксису, а зміною парадигми на користь функціонального програмування з використанням концепції чистих функцій.
  2. Пропси (Props) забезпечують формальний Data-контракт між компонентами. Вони є імутабельними, а спроба їх змінити порушує архітектуру додатку.
  3. Розуміння різниці між передачею посилань і примітивів у JS є критичним для запобігання зайвим циклам рендерингу (re-renders).
  4. TypeScript у комбінації з ComponentProps створює надійну систему контролю типів, запобігаючи використанню незадокументованих атрибутів типізуючи розриви в API (Breaking API Changes) на етапі компіляції.
  5. Inversion of Control через властивість children та делегування пропсів (через ...rest) дозволяють створювати гнучкі, слабо пов’язані (loosely-coupled) архітектурні рішення та ефективні бібліотеки базових компонентів (UI Kits).

Джерела

  1. Офіційна документація React. “Keeping Components Pure”. URL: https://react.dev/learn/keeping-components-pure
  2. React Docs. “Passing Props to a Component”. URL: https://react.dev/learn/passing-props-to-a-component
  3. TypeScript for React Developers. “Typing Component Props”. URL: https://react-typescript-cheatsheet.netlify.app/
  4. Dan Abramov. “A Complete Guide to useEffect”. Overreacted. URL: https://overreacted.io/a-complete-guide-to-useeffect/
  5. “The React Handbook”. Flavio Copes. (Розділ: Component Architecture and Composition).
  6. “Advanced React Patterns”. Kent C. Dodds. (Розділ: Inversion of Control).
  7. “JavaScript: The Good Parts”. Douglas Crockford. (Основи розуміння замикань та передачі об’єктів посиланням).
  8. “Clean Architecture: A Craftsman’s Guide to Software Structure”. Robert C. Martin.
  9. “Functional Programming in JavaScript”. Luis Atencio.
  10. MDN Web Docs. “Object.is()”. URL: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is

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

  1. З точки зору комп’ютерних наук та функціонального програмування, які дві ключові вимоги висуваються до компонента, щоб вважати його “чистою функцією”?
  2. Чому спроба безпосередньо змінити властивість об’єкта props (наприклад, props.user.name = 'Нове ім'я') є антипатерном і як саме це ламає архітектуру односпрямованого потоку даних?
  3. Поясніть сутність процесу “Lifting State Up” (підняття стану). Як дочірній компонент може ініціювати зміну даних, якщо пропси для нього є “read-only”?
  4. Які архітектурні переваги (з точки зору зв’язності коду) надає патерн передачі вмісту через children (створення Wrapper-компонентів)?
  5. Яку конкретну проблему вирішує статична типізація TypeScript при розробці бібліотеки компонентів рівня Enterprise? Як працює тип ComponentPropsWithoutRef?
  6. Поясніть явище “втрати мемоїзації”. Чому передача config= безпосередньо у пропси компонента, огорнутого в React.memo, призведе до його гарантованого ререндеру?