Пошук уроків, статей та іншого контенту
Навчіться читати Component Tree, визначати ієрархію компонентів і знаходити межі відповідальності в React-застосунку.
React-застосунок складається з компонентів, які вкладаються один в один. Таку ієрархію називають деревом компонентів (Component Tree).
Кожен компонент у дереві може:
містити інші компоненти;
отримувати дані від батьківського компонента через props;
зберігати власний стан;
відображати HTML-елементи або дочірні компоненти.
Наприклад, сторінку інтернет-магазину можна описати так:
App
└── StorePage
├── Header
│ ├── Logo
│ └── CartButton
├── ProductList
│ ├── ProductCard
│ ├── ProductCard
│ └── ProductCard
└── FooterЦе дерево показує не HTML-документ, а структуру React-компонентів.
Розглянемо простий React-застосунок:
function App() {
return (
<main>
<Header />
<ProductList />
<Footer />
</main>
);
}
function Header() {
return (
<header>
<Logo />
<CartButton />
</header>
);
}
function ProductList() {
return (
<section>
<ProductCard />
<ProductCard />
</section>
);
}
function Footer() {
return <footer>Інтернет-магазин</footer>;
}
function Logo() {
return <a href="/">Мій магазин</a>;
}
function CartButton() {
return <button>Кошик</button>;
}
function ProductCard() {
return (
<article>
<h2>Навушники</h2>
<p>Ціна: 1500 грн</p>
</article>
);
}Його дерево компонентів має такий вигляд:
App
├── Header
│ ├── Logo
│ └── CartButton
├── ProductList
│ ├── ProductCard
│ └── ProductCard
└── FooterЧитаючи це дерево, можна зробити такі висновки:
App — кореневий компонент цього фрагмента застосунку.
Header, ProductList і Footer — дочірні компоненти App.
Logo і CartButton — дочірні компоненти Header.
ProductCard — дочірній компонент ProductList.
Два екземпляри ProductCard є різними екземплярами одного компонента.
Компонент, який рендерить інший компонент, називають батьківським.
Компонент, який рендериться всередині іншого компонента, називають дочірнім.
У прикладі:
function App() {
return <Header />;
}App — батьківський компонент;
Header — дочірній компонент.
Вкладеність компонентів визначається JSX:
function Header() {
return (
<header>
<Logo />
<CartButton />
</header>
);
}Тут:
Header містить Logo;
Header містить CartButton;
Logo і CartButton перебувають на одному рівні дерева;
вони є сусідніми компонентами, але не батьківськими один для одного.
Більшість React-застосунків починається з кореневого компонента, наприклад App.
У сучасному React кореневий компонент підключають до DOM через createRoot:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
function App() {
return <h1>Мій React-застосунок</h1>;
}
const rootElement = document.querySelector("#root");
const root = createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>
);У цьому прикладі структура має такий вигляд:
StrictMode
└── AppА HTML-елемент h1 є вже результатом роботи компонента App:
StrictMode
└── App
└── h1Зазвичай під час проєктування застосунку насамперед цікавить дерево власних React-компонентів:
App
└── h1HTML-елементи також входять до результату рендерингу, але межі відповідальності найчастіше визначають саме між React-компонентами.
Компонент зазвичай відповідає за окрему частину інтерфейсу або окрему поведінку.
Наприклад, компонент сторінки профілю може мати таку структуру:
ProfilePage
├── ProfileHeader
│ ├── Avatar
│ └── UserName
├── ProfileInfo
│ ├── Email
│ └── RegistrationDate
└── LogoutButtonКожен компонент має зрозумілу роль:
ProfilePage об'єднує частини сторінки профілю;
ProfileHeader відповідає за верхню частину профілю;
Avatar показує зображення користувача;
ProfileInfo групує інформацію;
LogoutButton відповідає за кнопку виходу.
Таке розділення спрощує читання коду. Щоб змінити аватар, не потрібно шукати його серед усієї розмітки сторінки. Достатньо відкрити Avatar.
Межа відповідальності — це правило, яке визначає, за яку частину інтерфейсу або поведінки відповідає компонент.
Під час створення компонента корисно поставити кілька запитань:
Яку частину інтерфейсу він відображає?
Які дані йому потрібні?
Які дії він обробляє?
Чи можна описати його роль одним коротким реченням?
Чи не відповідає він одночасно за надто багато різних речей?
Наприклад, компонент ProductCard може відповідати за відображення одного товару:
function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{product.price} грн</p>
<button>Додати в кошик</button>
</article>
);
}Його відповідальність — показати дані одного товару та надати кнопку для дії, пов'язаної з цим товаром.
Натомість компонент ProductList відповідає за список товарів:
function ProductList({ products }) {
return (
<section>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</section>
);
}Тут відповідальності розділені:
ProductList перебирає масив товарів;
ProductCard показує один товар.
Розглянемо застосунок зі списком завдань:
import { useState } from "react";
const initialTasks = [
{ id: 1, title: "Вивчити компоненти", completed: true },
{ id: 2, title: "Побудувати дерево", completed: false },
];
function App() {
const [tasks, setTasks] = useState(initialTasks);
function handleToggleTask(taskId) {
setTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === taskId
? { ...task, completed: !task.completed }
: task
)
);
}
return (
<main>
<Header taskCount={tasks.length} />
<TaskList tasks={tasks} onToggleTask={handleToggleTask} />
</main>
);
}
function Header({ taskCount }) {
return (
<header>
<h1>Мої завдання</h1>
<p>Усього завдань: {taskCount}</p>
</header>
);
}
function TaskList({ tasks, onToggleTask }) {
return (
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggleTask}
/>
))}
</ul>
);
}
function TaskItem({ task, onToggle }) {
return (
<li>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
{task.title}
</label>
</li>
);
}Дерево цього застосунку:
App
├── Header
└── TaskList
├── TaskItem
└── TaskItemВідповідальність компонентів:
App зберігає список завдань і змінює його;
Header показує заголовок і кількість завдань;
TaskList створює список компонентів TaskItem;
TaskItem показує одне завдання та повідомляє про зміну його стану.
У React дані зазвичай передаються від батьківського компонента до дочірнього через props.
У попередньому прикладі App передає дані компоненту Header:
<Header taskCount={tasks.length} />А також передає дані та функцію компоненту TaskList:
<TaskList
tasks={tasks}
onToggleTask={handleToggleTask}
/>Потім TaskList передає частину цих даних кожному TaskItem:
<TaskItem
task={task}
onToggle={onToggleTask}
/>Напрямок можна подати так:
App
│
├── taskCount → Header
│
└── tasks, onToggleTask → TaskList
│
└── task, onToggle → TaskItemБатьківський компонент не передає дані через випадкові глобальні змінні. Він явно передає їх дочірньому компоненту через атрибути JSX.
Дочірній компонент може повідомити батьківський про дію через функцію, передану в props.
У прикладі App створює функцію:
function handleToggleTask(taskId) {
setTasks((currentTasks) =>
currentTasks.map((task) =>
task.id === taskId
? { ...task, completed: !task.completed }
: task
)
);
}Потім передає її вниз:
<TaskList onToggleTask={handleToggleTask} />TaskList передає її далі:
<TaskItem onToggle={onToggleTask} />А TaskItem викликає її після зміни прапорця:
<input
type="checkbox"
onChange={() => onToggle(task.id)}
/>Отже, подія починається в дочірньому компоненті, але змінювати стан може батьківський компонент.
TaskItem
│
└── викликає onToggle
│
▼
App змінює tasksСтан варто зберігати в тому компоненті, якому він потрібен для керування даними.
У прикладі список завдань і функція його зміни перебувають у App:
function App() {
const [tasks, setTasks] = useState(initialTasks);
// ...
}Чому саме в App?
Header потребує кількість завдань;
TaskList потребує самі завдання;
TaskItem потребує одне завдання;
App є спільним батьківським компонентом для всіх цих частин.
Якби стан зберігався лише в TaskItem, окремі елементи не мали б спільного джерела даних для всього списку.
Це допомагає визначити місце компонента в дереві: спільні дані часто зберігаються в найближчому спільному батьківському компоненті.
Іноді один компонент стає надто великим і має кілька незалежних відповідальностей.
Наприклад:
function ProductPage() {
// Отримання товару
// Відображення заголовка
// Відображення галереї
// Відображення характеристик
// Робота з кошиком
// Відображення відгуків
}Замість цього можна створити дерево:
ProductPage
├── ProductHeader
├── ProductGallery
├── ProductDetails
├── AddToCartButton
└── ReviewListРозділення доречне, якщо:
частина інтерфейсу має власну зрозумілу роль;
компонент складно читати;
частину можна повторно використати;
у частини є власні props або стан;
зміни в одній частині часто не пов'язані зі змінами в іншій.
Не потрібно створювати окремий компонент для кожного HTML-елемента. Компонент має бути корисною одиницею інтерфейсу або поведінки.
Якщо ви відкрили новий React-проєкт, рухайтеся від кореня до дочірніх компонентів:
Знайдіть файл, у якому викликається root.render.
Визначте кореневий компонент, зазвичай App.
Подивіться, які компоненти повертає App.
Для кожного дочірнього компонента визначте його власні дочірні компоненти.
Простежте, через які props передаються дані.
Знайдіть компоненти, у яких зберігається стан.
Визначте відповідальність кожної важливої гілки дерева.
Наприклад, якщо код містить:
function App() {
return (
<>
<Navigation />
<Dashboard />
</>
);
}Спочатку потрібно дослідити Navigation і Dashboard, а не одразу всі кнопки та абзаци всередині них.
Так поступово формується карта застосунку:
App
├── Navigation
└── Dashboard
├── Statistics
└── RecentActivityКомпонент, який містить усю сторінку, логіку даних і всі елементи інтерфейсу, важко читати та змінювати.
Краще виділити логічні частини в окремі компоненти:
Page
├── PageHeader
├── MainContent
└── PageFooterНаприклад, один компонент одночасно відображає меню, список товарів і форму оплати. Це різні частини інтерфейсу, тому їх краще розділити.
ProductCard і article — не одне й те саме:
ProductCard — React-компонент;
article — HTML-елемент, який компонент повертає.
Якщо кілька компонентів використовують одні й ті самі дані, стан зазвичай має бути піднятий до їхнього спільного батьківського компонента.
propsНазви data, value або item без контексту можуть ускладнювати розуміння дерева. Краще використовувати назви, які описують призначення:
<UserCard user={user} />
<ProductCard product={product} />
<TaskItem task={task} />Не кожен рядок JSX потрібно виносити в компонент. Якщо компонент не має окремої ролі, таке розділення може лише ускладнити структуру.
Дерево компонентів показує ієрархію React-компонентів.
Компонент, який рендерить інший компонент, є його батьківським компонентом.
App часто є коренем дерева застосунку.
Дані зазвичай передаються вниз через props.
Функції-обробники також можна передавати вниз, щоб дочірній компонент повідомляв батьківський про події.
Кожен компонент має мати зрозумілу межу відповідальності.
Спільний стан варто зберігати у найближчому спільному батьківському компоненті.
Щоб зрозуміти незнайомий React-застосунок, рухайтеся деревом від кореневого компонента до його дочірніх компонентів.