From Html To Project

Компоненти — будівельні блоки інтерфейсу

Фундаментальні принципи компонентної архітектури React — що таке компонент, як створювати функціональні компоненти, композиція проти успадкування, передача пропсів, деструктуризація, children та найкращі практики побудови переповторно використовуваних UI-блоків.

Компоненти — будівельні блоки інтерфейсу

🎯 Мета лекції

  • Зрозуміти філософію компонентного підходу у React — розбиття UI на незалежні, переповторно використовувані частини.
  • Опанувати створення функціональних компонентів із типізацією TypeScript.
  • Навчитися передавати дані через props та правильно типізувати їх.
  • Усвідомити принцип композиції компонентів через children.
  • Застосовувати найкращі практики іменування, структурування та організації компонентів.

🔑 Ключові терміни

  • Компонент (component) — функція, що повертає React Elements (JSX), описуючи частину UI.
  • Props (properties) — незмінні дані, що передаються від батьківського компонента до дочірнього.
  • Композиція (composition) — побудова складних компонентів із простих через вкладення та передачу children.
  • Деструктуризація (destructuring) — витягування властивостей із об'єкта props для зручності.
  • Children — спеціальний пропс, що містить вміст між відкриваючим та закриваючим тегами компонента.

🔗 Офіційний референс React:
Ваш перший компонент · Передача пропсів до компонента · Композиція проти успадкування


Філософія компонентів: Divide and Conquer

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

React застосовує той самий принцип до UI. Замість одного величезного HTML-файлу ви розбиваєте інтерфейс на компоненти — самодостатні, переповторно використовувані частини, кожна з яких відповідає за свій шматок функціональності.

Проблема монолітного коду

Розглянемо типовий веб-застосунок без компонентного підходу:

<!-- index.html — 2000+ рядків HTML -->
<!DOCTYPE html>
<html>
<head>
  <title>Застосунок</title>
  <style>
    /* 500+ рядків CSS */
    .header { ... }
    .navigation { ... }
    .user-profile { ... }
    .sidebar { ... }
    .content { ... }
    .footer { ... }
  </style>
</head>
<body>
  <!-- Хедер -->
  <header class="header">
    <nav class="navigation">
      <a href="/">Головна</a>
      <a href="/about">Про нас</a>
      <!-- ... 50 рядків навігації -->
    </nav>
    <div class="user-profile">
      <img src="avatar.jpg" />
      <span id="username">Користувач</span>
      <!-- ... 30 рядків профілю -->
    </div>
  </header>

  <!-- Основний контент -->
  <div class="main-content">
    <aside class="sidebar">
      <!-- ... 200 рядків сайдбару -->
    </aside>
    <main class="content">
      <!-- ... 1000 рядків контенту -->
    </main>
  </div>

  <!-- Футер -->
  <footer class="footer">
    <!-- ... 100 рядків футера -->
  </footer>

  <script>
    // 1000+ рядків JavaScript
    document.getElementById('username').textContent = 'Іван';
    // ... десятки обробників подій
  </script>
</body>
</html>

Проблеми цього підходу:

  1. Складність читання: Неможливо швидко зрозуміти структуру — усе змішано в одному файлі.
  2. Відсутність переповторного використання: Якщо потрібен той самий профіль користувача на іншій сторінці — доведеться копіювати HTML та CSS.
  3. Конфлікти імен: Класи .user-profile, .button, .card можуть випадково збігтися у різних частинах застосунку.
  4. Тестування: Неможливо протестувати окремий шматок UI — усе зв'язано.
  5. Командна робота: Кілька розробників не можуть працювати над одним файлом без конфліктів у Git.

Компонентний підхід React

React пропонує розбити цей монолітний код на самодостатні компоненти:

App (кореневий компонент)
├── Header
│   ├── Navigation
│   │   ├── NavLink
│   │   ├── NavLink
│   │   └── NavLink
│   └── UserProfile
│       ├── Avatar
│       └── UserMenu
├── MainContent
│   ├── Sidebar
│   │   └── SidebarItem (x5)
│   └── Content
│       └── Article (x10)
└── Footer
    └── SocialLinks

Кожен компонент — це окремий файл із власною логікою, стилями та типізацією:

src/
├── components/
│   ├── Header/
│   │   ├── Header.tsx
│   │   ├── Header.css
│   │   └── Header.test.tsx
│   ├── Navigation/
│   │   ├── Navigation.tsx
│   │   └── Navigation.css
│   ├── UserProfile/
│   │   ├── UserProfile.tsx
│   │   └── UserProfile.css
│   └── ... (інші компоненти)
└── App.tsx

Переваги:

  • Модульність: Кожен компонент — незалежна одиниця, яку можна розробляти, тестувати та документувати окремо.
  • Переповторне використання: <Button>, <Card>, <Modal> можна використовувати скрізь у застосунку.
  • Ізоляція: Зміни у Header не впливають на Footer.
  • Командна робота: Різні розробники працюють над різними компонентами без конфліктів.
Аналогія з функціями у програмуванні:Компоненти у React — це як функції у звичайному програмуванні. Ви не пишете весь код в одній функції main() на 10,000 рядків. Замість цього ви розбиваєте логіку на окремі функції з чіткими обов'язками:
function calculateTotal(items) { ... }
function validateEmail(email) { ... }
function formatDate(date) { ... }
Так само у React ви розбиваєте UI на компоненти з чіткими обов'язками:
function Button(props) { ... }
function UserCard(props) { ... }
function Modal(props) { ... }

Що таке компонент у React?

Компонент — це JavaScript-функція, яка приймає об'єкт props (властивості) та повертає React Elements (JSX), що описують, як повинна виглядати частина UI.

Найпростіший компонент

// src/components/Greeting.tsx
function Greeting() {
  return <h1>Привіт, світ!</h1>;
}

export default Greeting;

Це вже повноцінний React-компонент! Він:

  1. Називається з великої літери (Greeting, не greeting) — це конвенція React.
  2. Повертає JSX — опис того, що повинно бути на екрані.
  3. Експортується — щоб інші компоненти могли його імпортувати.

Використання компонента

// src/App.tsx
import Greeting from './components/Greeting';

function App() {
  return (
    <div>
      <Greeting />
      <Greeting />
      <Greeting />
    </div>
  );
}

export default App;

Результат у DOM:

<div>
  <h1>Привіт, світ!</h1>
  <h1>Привіт, світ!</h1>
  <h1>Привіт, світ!</h1>
</div>
Компоненти — це звичайні функції:Під капотом <Greeting /> перетворюється на виклик функції:
// JSX
<Greeting />

// Трансформація
_jsx(Greeting, {})

// React викликає
const element = Greeting({});
React викликає вашу функцію та отримує React Elements, які потім перетворюються на реальний DOM.

Іменування компонентів: PascalCase

Правило: Імена компонентів завжди починаються з великої літери.

// ✅ ПРАВИЛЬНО — PascalCase
function UserProfile() { ... }
function NavBar() { ... }
function CardHeader() { ... }

// ❌ НЕПРАВИЛЬНО — camelCase
function userProfile() { ... }
function navBar() { ... }

Чому це важливо?

React розрізняє компоненти та HTML-теги за регістром першої літери:

  • Мала літера (<div>, <button>) — React розуміє як HTML-тег.
  • Велика літера (<UserProfile>, <Button>) — React розуміє як компонент.
// <button> — HTML-тег
<button>Натисни</button>

// <Button> — React-компонент
<Button>Натисни</Button>

Якщо ви назвете компонент з малої літери, React спробує знайти HTML-тег із такою назвою:

function greeting() {  // ❌ Мала літера!
  return <h1>Привіт</h1>;
}

// JSX
<greeting />

// React шукає HTML-тег <greeting>, а не викликає функцію
// Результат: <greeting></greeting> у DOM (невалідний HTML)

Props: передача даних до компонентів

Компоненти стають корисними, коли ви можете передавати їм дані. Це робиться через props (properties — властивості).

Базовий приклад: компонент із пропсами

// src/components/Greeting.tsx
interface GreetingProps {
  name: string;
}

function Greeting(props: GreetingProps) {
  return <h1>Привіт, {props.name}!</h1>;
}

export default Greeting;

Використання:

// src/App.tsx
import Greeting from './components/Greeting';

function App() {
  return (
    <div>
      <Greeting name="Олександр" />
      <Greeting name="Марія" />
      <Greeting name="Іван" />
    </div>
  );
}

Результат у DOM:

<div>
  <h1>Привіт, Олександр!</h1>
  <h1>Привіт, Марія!</h1>
  <h1>Привіт, Іван!</h1>
</div>
Що відбувається під капотом:Коли ви пишете <Greeting name="Олександр" />, React викликає вашу функцію та передає пропси як об'єкт:
Greeting({ name: "Олександр" })
Усі атрибути JSX-тегу збираються в один об'єкт props. Тому props.name дорівнює "Олександр".

Деструктуризація пропсів

Замість того, щоб кожного разу писати props.name, props.age, можна деструктурувати об'єкт props прямо у параметрах функції:

// Без деструктуризації
function UserCard(props: { name: string; age: number; role: string }) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>Вік: {props.age}</p>
      <p>Роль: {props.role}</p>
    </div>
  );
}

// З деструктуризацією (рекомендований стиль)
function UserCard({ name, age, role }: { name: string; age: number; role: string }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>Вік: {age}</p>
      <p>Роль: {role}</p>
    </div>
  );
}

Переваги деструктуризації:

  • Коротший код: name замість props.name.
  • Явність: Відразу видно, які пропси використовує компонент.
  • Автодоповнення: TypeScript краще підказує властивості.

Винесення типу у окремий інтерфейс:

interface UserCardProps {
  name: string;
  age: number;
  role: string;
}

function UserCard({ name, age, role }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>Вік: {age}</p>
      <p>Роль: {role}</p>
    </div>
  );
}
Конвенція іменування типів пропсів:Називайте інтерфейс пропсів як {ComponentName}Props:
interface ButtonProps { ... }
interface ModalProps { ... }
interface UserProfileProps { ... }
Це стандарт у React-спільноті, що полегшує навігацію по коду.

Опціональні пропси

Не всі пропси обов'язкові. У TypeScript опціональні властивості позначаються знаком ?:

interface GreetingProps {
  name: string;
  age?: number;  // Опціональний пропс
  role?: string; // Опціональний пропс
}

function Greeting({ name, age, role }: GreetingProps) {
  return (
    <div>
      <h1>Привіт, {name}!</h1>
      {age && <p>Вік: {age}</p>}
      {role && <p>Роль: {role}</p>}
    </div>
  );
}

// Використання
<Greeting name="Олександр" />  {/* age та role не передані */}
<Greeting name="Марія" age={25} role="Designer" />

Значення за замовчуванням:

Якщо пропс не переданий, можна встановити значення за замовчуванням через деструктуризацію:

interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

function Button({ label, variant = 'primary', disabled = false }: ButtonProps) {
  return (
    <button 
      className={`btn btn-${variant}`}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

// Використання
<Button label="Зберегти" />  
// variant буде 'primary', disabled буде false

<Button label="Скасувати" variant="secondary" />
// variant буде 'secondary', disabled буде false

Props є незмінними (immutable)

Фундаментальне правило React: компонент ніколи не повинен змінювати свої пропси.

function UserCard({ name, age }: { name: string; age: number }) {
  // ❌ НЕПРАВИЛЬНО — спроба змінити props
  name = "Інше ім'я";  // TypeScript покаже помилку!
  
  return <div>{name}</div>;
}

Чому props є read-only?

  1. Однонаправлений потік даних: Дані рухаються від батьківського компонента до дочірнього. Якщо дочірній змінить props, батьківський не дізнається про це — виникне неузгодженість стану.
  2. Передбачуваність: React покладається на те, що props не змінюються під час рендерингу. Якщо ви змінюєте props, React не може ефективно визначити, що оновити.
  3. Чисті функції: React-компоненти мають бути чистими функціями — вони повертають однаковий результат для однакових вхідних даних (props) без побічних ефектів.
Чисті функції у програмуванні:Чиста функція — це функція, яка:
  1. Повертає однаковий результат для однакових аргументів.
  2. Не має побічних ефектів — не змінює зовнішній стан, не викликає IO-операції, не змінює аргументи.
// ✅ Чиста функція
function sum(a, b) {
  return a + b;
}

// ❌ Нечиста функція — змінює зовнішню змінну
let total = 0;
function addToTotal(value) {
  total += value;  // Побічний ефект!
  return total;
}

// ❌ Нечиста функція — змінює аргумент
function addItem(array, item) {
  array.push(item);  // Мутація аргумента!
  return array;
}
React-компоненти мають бути чистими відносно props:
// ✅ Чистий компонент
function Greeting({ name }: { name: string }) {
  return <h1>Привіт, {name}!</h1>;
}

// ❌ Нечистий компонент — змінює props
function Greeting({ name }: { name: string }) {
  name = name.toUpperCase();  // Мутація!
  return <h1>Привіт, {name}!</h1>;
}

Якщо потрібна локальна копія пропса:

Використовуйте локальний стан (useState), про який ми детально поговоримо у наступних лекціях:

import { useState } from 'react';

function EditableLabel({ initialValue }: { initialValue: string }) {
  // Створюємо локальну копію у стані
  const [value, setValue] = useState(initialValue);

  return (
    <input
      type="text"
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

Передача функцій через props

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

Приклад: кнопка з обробником події

// src/components/Button.tsx
interface ButtonProps {
  label: string;
  onClick: () => void;  // Функція без параметрів, що не повертає значення
}

function Button({ label, onClick }: ButtonProps) {
  return (
    <button onClick={onClick} className="btn">
      {label}
    </button>
  );
}

export default Button;

Використання:

// src/App.tsx
import { useState } from 'react';
import Button from './components/Button';

function App() {
  const [count, setCount] = useState(0);

  const handleIncrement = () => {
    setCount(count + 1);
  };

  const handleDecrement = () => {
    setCount(count - 1);
  };

  return (
    <div>
      <h1>Лічильник: {count}</h1>
      <Button label="Збільшити" onClick={handleIncrement} />
      <Button label="Зменшити" onClick={handleDecrement} />
    </div>
  );
}

Що відбувається:

  1. Батьківський компонент App передає функцію handleIncrement через пропс onClick.
  2. Дочірній компонент Button отримує цю функцію та викликає її при кліку на кнопку.
  3. Виклик функції змінює стан у батьківському компоненті, що призводить до перерендерингу.
Передача даних назад:Якщо дочірньому компоненту потрібно передати дані батьківському, функція у props може приймати параметри:
interface SearchInputProps {
  onSearch: (query: string) => void;  // Функція приймає рядок
}

function SearchInput({ onSearch }: SearchInputProps) {
  const [value, setValue] = useState('');

  const handleSubmit = () => {
    onSearch(value);  // Передаємо значення батьківському
  };

  return (
    <>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <button onClick={handleSubmit}>Шукати</button>
    </>
  );
}

// Використання
<SearchInput onSearch={(query) => console.log('Пошук:', query)} />

Типізація складних обробників подій

Для обробників подій з параметрами використовуйте точні типи:

interface ProductCardProps {
  id: number;
  name: string;
  price: number;
  onAddToCart: (productId: number, quantity: number) => void;
}

function ProductCard({ id, name, price, onAddToCart }: ProductCardProps) {
  const handleAdd = () => {
    onAddToCart(id, 1);  // Додаємо 1 одиницю товару
  };

  return (
    <div className="product-card">
      <h3>{name}</h3>
      <p>Ціна: {price} грн</p>
      <button onClick={handleAdd}>Додати до кошика</button>
    </div>
  );
}

Композиція через children

Одна з найпотужніших можливостей React — композиція компонентів через спеціальний пропс children.

Що таке children?

children — це вміст між відкриваючим та закриваючим тегами компонента.

<Card>
  <h2>Заголовок</h2>
  <p>Параграф</p>
</Card>

Усе, що знаходиться між <Card> та </Card>, автоматично передається як props.children.

Створення контейнерних компонентів

// src/components/Card.tsx
interface CardProps {
  children: React.ReactNode;  // Тип для children
}

function Card({ children }: CardProps) {
  return (
    <div className="card">
      {children}
    </div>
  );
}

export default Card;

Використання:

import Card from './components/Card';

function App() {
  return (
    <div>
      <Card>
        <h2>Заголовок картки</h2>
        <p>Це вміст картки.</p>
        <button>Дія</button>
      </Card>

      <Card>
        <img src="photo.jpg" alt="Фото" />
        <p>Опис фото</p>
      </Card>

      <Card>
        <ul>
          <li>Пункт 1</li>
          <li>Пункт 2</li>
        </ul>
      </Card>
    </div>
  );
}

Результат: Кожна <Card> обгортає свій вміст у <div className="card">, додаючи стилі та структуру.

Тип React.ReactNode:React.ReactNode — це універсальний тип для всього, що може бути відрендерене React:
type ReactNode =
  | ReactElement       // JSX-елементи (<div>, <Component />)
  | string             // Текст
  | number             // Числа
  | boolean            // true/false (не рендеряться)
  | null               // Не рендериться
  | undefined          // Не рендериться
  | ReactNode[]        // Масив будь-чого з вищезгаданого
Використовуйте React.ReactNode для children, якщо компонент приймає будь-який контент.

Композиція замість успадкування

У React композиція переважає над успадкуванням (composition over inheritance). Замість того, щоб створювати ієрархію класів через extends, ви комбінуєте компоненти.

Антипатерн (успадкування у класових компонентах, застаріле):

// ❌ Застарілий підхід — успадкування
class BaseButton extends React.Component {
  render() {
    return <button>{this.props.label}</button>;
  }
}

class PrimaryButton extends BaseButton {
  render() {
    return <button className="primary">{this.props.label}</button>;
  }
}

Сучасний підхід (композиція):

// ✅ Сучасний підхід — композиція
interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
}

function Button({ children, variant = 'primary' }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`}>
      {children}
    </button>
  );
}

// Використання
<Button variant="primary">Зберегти</Button>
<Button variant="secondary">Скасувати</Button>

Переваги композиції:

  • Гнучкість: Легко комбінувати різні компоненти.
  • Простота: Немає складних ланцюгів успадкування.
  • Переповторне використання: Компоненти незалежні один від одного.
React Композиція компонентів через children
Файли проєкту
src
App.tsx
App.css
localhost:3000

Збирання React застосунку…


Іменовані слоти через комбінацію props

Іноді потрібно передати кілька незалежних фрагментів контенту у різні частини компонента. Це досягається через комбінацію звичайних props та children.

// src/components/Modal.tsx
interface ModalProps {
  children: React.ReactNode;      // Основний контент (body)
  header: React.ReactNode;         // Заголовок
  footer?: React.ReactNode;        // Футер (опціонально)
  onClose: () => void;
}

function Modal({ children, header, footer, onClose }: ModalProps) {
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal" onClick={(e) => e.stopPropagation()}>
        <div className="modal-header">
          {header}
          <button className="close-btn" onClick={onClose}>×</button>
        </div>
        
        <div className="modal-body">
          {children}
        </div>

        {footer && (
          <div className="modal-footer">
            {footer}
          </div>
        )}
      </div>
    </div>
  );
}

export default Modal;

Використання:

import { useState } from 'react';
import Modal from './components/Modal';

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Відкрити модалку</button>

      {isOpen && (
        <Modal
          header={<h2>Підтвердження дії</h2>}
          footer={
            <>
              <button onClick={() => setIsOpen(false)}>Скасувати</button>
              <button onClick={() => alert('Підтверджено!')}>Підтвердити</button>
            </>
          }
          onClose={() => setIsOpen(false)}
        >
          <p>Ви впевнені, що хочете видалити цей елемент?</p>
          <p>Цю дію неможливо скасувати.</p>
        </Modal>
      )}
    </div>
  );
}

Переваги підходу:

  • Гнучкість: Можна передати будь-який JSX у кожен слот.
  • Типобезпека: TypeScript перевіряє, що ви передали всі обов'язкові пропси.
  • Читабельність: Чітко видно, що саме передається у header, body та footer.

Розбиття UI на компоненти: практичний приклад

Розглянемо процес розбиття реального UI на компоненти.

Крок 1: Аналіз дизайну

Уявімо картку товару у інтернет-магазині:

┌────────────────────────────────────┐
│  [Зображення товару]               │
│                                    │
│  Назва товару                      │
│  ★★★★☆ (24 відгуки)                │
│  1 200 грн                         │
│                                    │
│  [Додати до кошика]  [♥]           │
└────────────────────────────────────┘

Крок 2: Визначення компонентів

Розбиваємо на логічні частини:

ProductCard (контейнер)
├── ProductImage
├── ProductTitle
├── ProductRating
├── ProductPrice
└── ProductActions
    ├── AddToCartButton
    └── FavoriteButton

Крок 3: Типізація даних

// src/types/Product.ts
export interface Product {
  id: number;
  title: string;
  price: number;
  imageUrl: string;
  rating: number;
  reviewCount: number;
}

Крок 4: Створення компонентів

// src/components/ProductCard/ProductImage.tsx
interface ProductImageProps {
  src: string;
  alt: string;
}

function ProductImage({ src, alt }: ProductImageProps) {
  return (
    <div className="product-image">
      <img src={src} alt={alt} />
    </div>
  );
}

export default ProductImage;
// src/components/ProductCard/ProductRating.tsx
interface ProductRatingProps {
  rating: number;
  reviewCount: number;
}

function ProductRating({ rating, reviewCount }: ProductRatingProps) {
  const fullStars = Math.floor(rating);
  const hasHalfStar = rating % 1 >= 0.5;

  return (
    <div className="product-rating">
      <div className="stars">
        {'★'.repeat(fullStars)}
        {hasHalfStar && '⯨'}
        {'☆'.repeat(5 - fullStars - (hasHalfStar ? 1 : 0))}
      </div>
      <span className="review-count">({reviewCount} відгуків)</span>
    </div>
  );
}

export default ProductRating;
// src/components/ProductCard/ProductCard.tsx
import { Product } from '../../types/Product';
import ProductImage from './ProductImage';
import ProductRating from './ProductRating';

interface ProductCardProps {
  product: Product;
  onAddToCart: (productId: number) => void;
  onToggleFavorite: (productId: number) => void;
}

function ProductCard({ product, onAddToCart, onToggleFavorite }: ProductCardProps) {
  return (
    <div className="product-card">
      <ProductImage src={product.imageUrl} alt={product.title} />
      
      <h3 className="product-title">{product.title}</h3>
      
      <ProductRating rating={product.rating} reviewCount={product.reviewCount} />
      
      <div className="product-price">{product.price} грн</div>

      <div className="product-actions">
        <button 
          className="btn-primary"
          onClick={() => onAddToCart(product.id)}
        >
          Додати до кошика
        </button>
        <button 
          className="btn-icon"
          onClick={() => onToggleFavorite(product.id)}
        >
          ♥
        </button>
      </div>
    </div>
  );
}

export default ProductCard;

Крок 5: Використання у застосунку

// src/App.tsx
import { useState } from 'react';
import ProductCard from './components/ProductCard/ProductCard';
import { Product } from './types/Product';

function App() {
  const [products] = useState<Product[]>([
    {
      id: 1,
      title: 'Бездротові навушники',
      price: 1200,
      imageUrl: '/headphones.jpg',
      rating: 4.5,
      reviewCount: 24,
    },
    {
      id: 2,
      title: 'Механічна клавіатура',
      price: 2500,
      imageUrl: '/keyboard.jpg',
      rating: 4.8,
      reviewCount: 156,
    },
  ]);

  const handleAddToCart = (productId: number) => {
    console.log('Додано до кошика:', productId);
  };

  const handleToggleFavorite = (productId: number) => {
    console.log('Перемикання улюбленого:', productId);
  };

  return (
    <div className="product-grid">
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
          onToggleFavorite={handleToggleFavorite}
        />
      ))}
    </div>
  );
}

export default App;
Переваги розбиття на дрібні компоненти:
  1. Переповторне використання: ProductRating можна використати у інших місцях (у списках, на сторінці товару).
  2. Тестування: Легше тестувати ProductRating окремо, ніж весь ProductCard.
  3. Підтримуваність: Зміни у рейтингу торкаються лише ProductRating, а не всього ProductCard.
  4. Командна робота: Різні розробники можуть працювати над різними компонентами.
Коли НЕ варто розбивати на дрібні компоненти:Не створюйте компоненти заради компонентів. Якщо частина UI:
  • Не переповторно використовується в інших місцях.
  • Складається з 2-3 рядків JSX.
  • Не має власної логіки.
...тоді залишайте її як частину батьківського компонента. Над-інженерія (over-engineering) шкодить так само, як і недо-інженерія.Приклад над-інженерії:
// ❌ Занадто дрібно
function ProductPriceLabel({ children }: { children: React.ReactNode }) {
  return <span className="price-label">{children}</span>;
}

function ProductPriceCurrency({ children }: { children: React.ReactNode }) {
  return <span className="currency">{children}</span>;
}

function ProductPrice({ amount }: { amount: number }) {
  return (
    <div>
      <ProductPriceLabel>{amount}</ProductPriceLabel>
      <ProductPriceCurrency>грн</ProductPriceCurrency>
    </div>
  );
}

// ✅ Достатньо просто
function ProductPrice({ amount }: { amount: number }) {
  return <div className="price">{amount} грн</div>;
}

Організація файлів компонентів

Існує кілька підходів до структурування компонентів у проєкті.

Підхід 1: Плоска структура (малі проєкти)

src/
├── components/
│   ├── Button.tsx
│   ├── Card.tsx
│   ├── Header.tsx
│   ├── Footer.tsx
│   └── Modal.tsx
├── App.tsx
└── main.tsx

Переваги: Простота, швидкий пошук файлів.
Недоліки: Стає незручним у великих проєктах із 50+ компонентами.

Підхід 2: Папка на компонент (середні проєкти)

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.css
│   │   └── Button.test.tsx
│   ├── Card/
│   │   ├── Card.tsx
│   │   └── Card.css
│   └── Modal/
│       ├── Modal.tsx
│       ├── Modal.css
│       └── ModalHeader.tsx  (допоміжний компонент)
├── App.tsx
└── main.tsx

Переваги: Усі файли компонента у одному місці (логіка, стилі, тести).
Недоліки: Більше папок, довші шляхи імпортів.

Підхід 3: Feature-based структура (великі проєкти)

src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── RegisterForm.tsx
│   │   ├── hooks/
│   │   │   └── useAuth.ts
│   │   └── types.ts
│   ├── products/
│   │   ├── components/
│   │   │   ├── ProductCard.tsx
│   │   │   ├── ProductList.tsx
│   │   │   └── ProductFilter.tsx
│   │   ├── hooks/
│   │   └── types.ts
│   └── cart/
│       ├── components/
│       └── hooks/
├── shared/
│   └── components/
│       ├── Button/
│       ├── Card/
│       └── Modal/
├── App.tsx
└── main.tsx

Переваги: Логічне групування за функціональністю, масштабованість.
Недоліки: Більша складність для початківців.

Конвенція експортів:Якщо компонент знаходиться у окремій папці, створіть файл index.ts для зручного імпорту:
// src/components/Button/index.ts
export { default } from './Button';
export type { ButtonProps } from './Button';
Тепер можна імпортувати:
// Без index.ts
import Button from './components/Button/Button';

// З index.ts
import Button from './components/Button';

Найкращі практики створення компонентів

1. Один компонент — одна відповідальність

Кожен компонент має виконувати одну задачу і виконувати її добре.

// ❌ НЕПРАВИЛЬНО — компонент робить занадто багато
function UserDashboard() {
  return (
    <div>
      {/* Хедер */}
      <header>...</header>
      
      {/* Навігація */}
      <nav>...</nav>
      
      {/* Профіль */}
      <section>...</section>
      
      {/* Список постів */}
      <section>...</section>
      
      {/* Сайдбар */}
      <aside>...</aside>
      
      {/* Футер */}
      <footer>...</footer>
    </div>
  );
}

// ✅ ПРАВИЛЬНО — розбито на окремі компоненти
function UserDashboard() {
  return (
    <div>
      <Header />
      <Navigation />
      <UserProfile />
      <PostList />
      <Sidebar />
      <Footer />
    </div>
  );
}

2. Використовуйте деструктуризацію props

// ❌ Менш читабельно
function UserCard(props: UserCardProps) {
  return (
    <div>
      <h2>{props.name}</h2>
      <p>{props.email}</p>
    </div>
  );
}

// ✅ Читабельніше
function UserCard({ name, email }: UserCardProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{email}</p>
    </div>
  );
}

3. Явно типізуйте всі пропси

// ❌ Неявна типізація (або взагалі без типів у .jsx)
function Button(props) {
  return <button>{props.label}</button>;
}

// ✅ Явна типізація
interface ButtonProps {
  label: string;
  onClick?: () => void;
  disabled?: boolean;
}

function Button({ label, onClick, disabled = false }: ButtonProps) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

4. Використовуйте значення за замовчуванням

interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
}

// ✅ Значення за замовчуванням у деструктуризації
function Button({ label, variant = 'primary', size = 'medium' }: ButtonProps) {
  return (
    <button className={`btn btn-${variant} btn-${size}`}>
      {label}
    </button>
  );
}

5. Не передавайте занадто багато пропсів

Якщо компонент приймає 5+ пропсів, можливо, варто передавати об'єкт:

// ❌ Занадто багато окремих пропсів
<UserCard
  id={user.id}
  name={user.name}
  email={user.email}
  avatar={user.avatar}
  role={user.role}
  isActive={user.isActive}
/>

// ✅ Передача об'єкта
interface UserCardProps {
  user: User;  // Один об'єкт замість 6 пропсів
}

<UserCard user={user} />

6. Префіксуйте обробники подій з handle або on

// Конвенція іменування обробників:
// - Пропси: onXxx (що передається компоненту)
// - Локальні функції: handleXxx (що оброблює подію)

interface ButtonProps {
  onClick: () => void;  // Пропс
}

function MyComponent() {
  const handleClick = () => {  // Локальна функція
    console.log('Clicked!');
  };

  return <Button onClick={handleClick} />;
}

7. Експортуйте типи пропсів

// Button.tsx
export interface ButtonProps {
  label: string;
  onClick?: () => void;
}

export function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

// Тепер інші компоненти можуть використовувати ButtonProps
import { ButtonProps } from './Button';

Практика: побудова системи карток

Створимо гнучку систему карток, що демонструє всі принципи, які ми розглянули.

React Система карток із композицією
Файли проєкту
src
App.tsx
App.css
localhost:3000

Збирання React застосунку…

Що демонструє цей приклад:

  1. Композиція: UserCard будується з Card, CardHeader, CardBody, CardFooter.
  2. Переповторне використання: Ті самі компоненти використовуються у різних контекстах.
  3. Типобезпека: Всі props типізовані через TypeScript.
  4. Деструктуризація: Чітко видно, які props використовує кожен компонент.
  5. Передача функцій: onFollow та onMessage дозволяють комунікувати з батьківським компонентом.

Поширені помилки та антипатерни

Помилка 1: Занадто великі компоненти

// ❌ АНТИПАТЕРН — компонент на 500+ рядків
function Dashboard() {
  // 50 рядків стану
  const [user, setUser] = useState(...);
  const [posts, setPosts] = useState(...);
  // ... ще 10 useState

  // 100 рядків обробників подій
  const handleSubmit = () => { ... };
  const handleDelete = () => { ... };
  // ... ще 20 функцій

  // 300 рядків JSX
  return (
    <div>
      {/* Величезна структура */}
    </div>
  );
}

Рішення: Розбийте на дрібніші компоненти за обов'язками.

Помилка 2: Props drilling (пробивання пропсів)

// ❌ АНТИПАТЕРН — пропси передаються через 5 рівнів
function App() {
  const [user, setUser] = useState(...);
  return <Layout user={user} />;
}

function Layout({ user }) {
  return <Sidebar user={user} />;
}

function Sidebar({ user }) {
  return <Navigation user={user} />;
}

function Navigation({ user }) {
  return <UserMenu user={user} />;
}

function UserMenu({ user }) {
  return <div>{user.name}</div>;
}

Рішення: Використовуйте Context API або state management (Redux, Zustand), про що ми поговоримо у наступних лекціях.

Помилка 3: Відсутність key у списках

// ❌ НЕПРАВИЛЬНО
{users.map(user => (
  <UserCard user={user} />  // Немає key!
))}

// ✅ ПРАВИЛЬНО
{users.map(user => (
  <UserCard key={user.id} user={user} />
))}

Помилка 4: Використання index як key

// ⚠️ НЕБЕЗПЕЧНО
{items.map((item, index) => (
  <Item key={index} data={item} />
))}

Використовуйте index як key ЛИШЕ якщо:

  • Список статичний (ніколи не змінюється).
  • Елементи не мають унікального id.
  • Елементи не можуть бути відсортовані або відфільтровані.

У всіх інших випадках використовуйте унікальний ідентифікатор.


Підсумок

✅ Що ми опанували

  • Компонент — це функція, що повертає JSX і описує частину UI.
  • Props — незмінні дані, що передаються від батьківського компонента до дочірнього.
  • Композиція через children дозволяє будувати складні компоненти з простих.
  • Деструктуризація props робить код читабельнішим.
  • TypeScript типізація забезпечує безпеку та автодоповнення.

🔑 Ключові принципи

  • Один компонент — одна відповідальність (Single Responsibility Principle).
  • Композиція переважає над успадкуванням (Composition over Inheritance).
  • Props є read-only — ніколи не змінюйте їх.
  • Компоненти мають бути чистими функціями відносно props.
  • Використовуйте унікальні key у списках для ефективного reconciliation.
Наступні кроки:У наступній лекції ми детально розглянемо стан компонентів (state) та хук useState — як зберігати та змінювати дані всередині компонентів, що призводить до їхнього перерендерингу.

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

Copyright © 2026