Компоненти — будівельні блоки інтерфейсу
Компоненти — будівельні блоки інтерфейсу
🎯 Мета лекції
- Зрозуміти філософію компонентного підходу у 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>
Проблеми цього підходу:
- Складність читання: Неможливо швидко зрозуміти структуру — усе змішано в одному файлі.
- Відсутність переповторного використання: Якщо потрібен той самий профіль користувача на іншій сторінці — доведеться копіювати HTML та CSS.
- Конфлікти імен: Класи
.user-profile,.button,.cardможуть випадково збігтися у різних частинах застосунку. - Тестування: Неможливо протестувати окремий шматок UI — усе зв'язано.
- Командна робота: Кілька розробників не можуть працювати над одним файлом без конфліктів у 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. - Командна робота: Різні розробники працюють над різними компонентами без конфліктів.
main() на 10,000 рядків. Замість цього ви розбиваєте логіку на окремі функції з чіткими обов'язками:function calculateTotal(items) { ... }
function validateEmail(email) { ... }
function formatDate(date) { ... }
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-компонент! Він:
- Називається з великої літери (
Greeting, неgreeting) — це конвенція React. - Повертає JSX — опис того, що повинно бути на екрані.
- Експортується — щоб інші компоненти могли його імпортувати.
Використання компонента
// 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({});
Іменування компонентів: 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: "Олександр" })
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 { ... }
Опціональні пропси
Не всі пропси обов'язкові. У 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?
- Однонаправлений потік даних: Дані рухаються від батьківського компонента до дочірнього. Якщо дочірній змінить props, батьківський не дізнається про це — виникне неузгодженість стану.
- Передбачуваність: React покладається на те, що props не змінюються під час рендерингу. Якщо ви змінюєте props, React не може ефективно визначити, що оновити.
- Чисті функції: React-компоненти мають бути чистими функціями — вони повертають однаковий результат для однакових вхідних даних (props) без побічних ефектів.
- Повертає однаковий результат для однакових аргументів.
- Не має побічних ефектів — не змінює зовнішній стан, не викликає 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;
}
// ✅ Чистий компонент
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>
);
}
Що відбувається:
- Батьківський компонент
Appпередає функціюhandleIncrementчерез пропсonClick. - Дочірній компонент
Buttonотримує цю функцію та викликає її при кліку на кнопку. - Виклик функції змінює стан у батьківському компоненті, що призводить до перерендерингу.
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 застосунку…
Іменовані слоти через комбінацію props
Іноді потрібно передати кілька незалежних фрагментів контенту у різні частини компонента. Це досягається через комбінацію звичайних props та children.
Приклад: модальне вікно з header, body та footer
// 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;
- Переповторне використання:
ProductRatingможна використати у інших місцях (у списках, на сторінці товару). - Тестування: Легше тестувати
ProductRatingокремо, ніж весьProductCard. - Підтримуваність: Зміни у рейтингу торкаються лише
ProductRating, а не всьогоProductCard. - Командна робота: Різні розробники можуть працювати над різними компонентами.
- Не переповторно використовується в інших місцях.
- Складається з 2-3 рядків JSX.
- Не має власної логіки.
// ❌ Занадто дрібно
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 застосунку…
Що демонструє цей приклад:
- Композиція:
UserCardбудується зCard,CardHeader,CardBody,CardFooter. - Переповторне використання: Ті самі компоненти використовуються у різних контекстах.
- Типобезпека: Всі props типізовані через TypeScript.
- Деструктуризація: Чітко видно, які props використовує кожен компонент.
- Передача функцій:
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.
useState — як зберігати та змінювати дані всередині компонентів, що призводить до їхнього перерендерингу.Запитання для самоперевірки
React розрізняє HTML-теги та компоненти за регістром першої літери:
- Мала літера (
<div>,<button>) — React розуміє як HTML-тег. - Велика літера (
<Button>,<UserCard>) — React розуміє як компонент і викликає відповідну функцію.
Якщо ви назвете компонент з малої літери (<button>), React спробує знайти HTML-тег із такою назвою, а не викличе вашу функцію.
Приклад:
function button() { return <div>Кнопка</div>; } // ❌ Мала літера
<button /> // React шукає HTML-тег <button>, а не викликає функцію
function Button() { return <div>Кнопка</div>; } // ✅ Велика літера
<Button /> // React викликає функцію Button()
Ні, props є незмінними (immutable) та read-only. Спроба змінити props порушує фундаментальний принцип React — однонаправлений потік даних (unidirectional data flow).
Чому props є read-only:
- Передбачуваність: React покладається на те, що props не змінюються під час рендерингу.
- Ефективність: React оптимізує reconciliation, припускаючи незмінність props.
- Чисті функції: Компоненти мають бути чистими функціями — повертати однаковий результат для однакових вхідних даних.
Якщо потрібна локальна копія:
Використовуйте локальний стан через useState:
function EditableLabel({ initialValue }: { initialValue: string }) {
const [value, setValue] = useState(initialValue);
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
children — це спеціальний пропс, що містить вміст між відкриваючим та закриваючим тегами компонента.
Приклад:
<Card>
<h2>Заголовок</h2>
<p>Контент</p>
</Card>
Усе між <Card> та </Card> передається як props.children.
Реалізація компонента:
interface CardProps {
children: React.ReactNode;
}
function Card({ children }: CardProps) {
return <div className="card">{children}</div>;
}
Тип React.ReactNode охоплює все, що може бути відрендерено: JSX, рядки, числа, масиви, null, undefined.
Створюйте новий компонент, якщо:
- Частина UI переповторно використовується у кількох місцях.
- Логіка складна та заслуговує ізоляції (50+ рядків JSX).
- Потрібна окрема відповідальність (Single Responsibility Principle).
- Потрібне окреме тестування цієї частини UI.
- Потрібна окрема оптимізація (наприклад, через
React.memo).
НЕ створюйте новий компонент, якщо:
- Частина UI складається з 2-3 рядків JSX без логіки.
- Використовується лише в одному місці і не має власної відповідальності.
- Не має власного стану або обробників подій.
Приклад над-інженерії:
// ❌ Занадто дрібно — це просто <span>
function PriceLabel({ children }: { children: React.ReactNode }) {
return <span className="price">{children}</span>;
}
// ✅ Достатньо залишити у батьківському компоненті
<span className="price">{price}</span>
Технічно не обов'язково — TypeScript може вивести типи автоматично або можна використовувати any. Але настійно рекомендується для продакшн-коду.
Переваги типізації props:
- Виявлення помилок на етапі компіляції: TypeScript покаже помилку, якщо ви передасте неправильний тип.
- Автодоповнення: Редактор підказує доступні властивості.
- Документація: Інтерфейс props є самодокументованим контрактом.
- Рефакторинг: Зміна типу автоматично виявить всі місця, де потрібно оновити код.
Порівняння:
// ❌ Без типізації — помилки виявляться у runtime
function Button(props) {
return <button>{props.label}</button>;
}
<Button lable="Зберегти" /> // Typo! Помилка у runtime
// ✅ З типізацією — помилка на етапі компіляції
interface ButtonProps {
label: string;
}
function Button({ label }: ButtonProps) {
return <button>{label}</button>;
}
<Button lable="Зберегти" /> // TypeScript покаже помилку!
JSX та TSX — синтаксичний цукор, а не магія
Глибоке розуміння JSX та TSX — як саме JSX перетворюється на виклики функцій, що таке React.createElement та сучасний jsx-runtime, різниця між HTML та JSX, особливості типізації TSX у TypeScript та найпоширеніші помилки початківців.
JSX — розмітка всередині TypeScript
Практичне використання TypeScript-виразів у JSX — фігурні дужки як вікно у TypeScript, динамічне формування атрибутів, обчислювані стилі, умовний рендеринг через тернарні оператори та логічні вирази, робота з масивами та об'єктами у розмітці.