Пошук уроків, статей та іншого контенту
Застосовуйте поширені React-патерни для масштабування компонентів, керування станом і підтримки архітектурної узгодженості.
Патерн — це перевірений спосіб організувати код для типової задачі. У React патерни допомагають:
визначити, де має зберігатися стан;
розділити логіку та відображення;
повторно використовувати компоненти без копіювання коду;
зменшити зв’язність між частинами інтерфейсу;
зберегти передбачувану архітектуру під час масштабування.
Патерн не є готовим правилом для кожної ситуації. Його потрібно обирати відповідно до складності компонента, способу використання стану та меж відповідальності.
React переважно використовує композицію замість успадкування. Компоненти отримують частини інтерфейсу через children, props або спеціальні дочірні компоненти.
Порівняйте два підходи:
function Modal({ title, children, footer }) {
return (
<div className="modal">
<header>{title}</header>
<main>{children}</main>
<footer>{footer}</footer>
</div>
);
}
function UserForm() {
return (
<Modal
title="Профіль користувача"
footer={<button type="submit">Зберегти</button>}
>
<input name="name" placeholder="Ім’я" />
</Modal>
);
}Modal не знає, що саме буде всередині основної частини або підвалу. Він відповідає лише за структуру та поведінку модального вікна.
Це краще, ніж створювати окремі компоненти на кшталт:
UserModal;
ProductModal;
DeleteConfirmationModal.
Такі компоненти часто дублюють однакову структуру, але відрізняються деталями. Композиція залишає загальний компонент незалежним від конкретного домену.
childrenПідходить, коли внутрішній вміст має одну основну область:
function Card({ children }) {
return <section className="card">{children}</section>;
}Підходять, коли компонент має кілька логічних областей:
function PageLayout({ header, sidebar, children }) {
return (
<div className="page-layout">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}Підходять для складних компонентів, частини яких мають працювати як єдина система:
<Card>
<Card.Header>Заголовок</Card.Header>
<Card.Body>Вміст</Card.Body>
</Card>Цей підхід розглянемо детальніше на прикладі вкладок.
Compound components — це набір компонентів, які спільно керують одним станом і мають узгоджений API.
Поширені приклади:
Tabs, Tabs.List, Tabs.Tab, Tabs.Panel;
Select, Select.Trigger, Select.Options, Select.Option;
Accordion, Accordion.Item, Accordion.Header, Accordion.Content.
Зовнішній код описує структуру інтерфейсу декларативно, а внутрішня реалізація приховує спосіб синхронізації компонентів.
Для спільного стану зазвичай використовують Context. Важливо, щоб контекст був внутрішньою деталлю компонента, а не глобальним сховищем для всього застосунку.
Нижче наведено повний приклад вкладок. Він підтримує обидва режими:
некерований — компонент сам зберігає активну вкладку;
керований — активна вкладка та її зміни контролюються батьківським компонентом.
import {
createContext,
useContext,
useId,
useMemo,
useState,
} from "react";
import { createRoot } from "react-dom/client";
const TabsContext = createContext(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Компоненти Tabs потрібно використовувати всередині Tabs");
}
return context;
}
function Tabs({
value,
defaultValue,
onChange,
children,
}) {
const [internalValue, setInternalValue] = useState(defaultValue);
const rootId = useId();
const isControlled = value !== undefined;
const activeValue = isControlled ? value : internalValue;
function setActiveValue(nextValue) {
if (!isControlled) {
setInternalValue(nextValue);
}
onChange?.(nextValue);
}
const contextValue = useMemo(
() => ({
activeValue,
setActiveValue,
rootId,
}),
[activeValue, rootId]
);
return (
<TabsContext.Provider value={contextValue}>
<div>{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ children }) {
return (
<div role="tablist" aria-label="Розділи">
{children}
</div>
);
}
function TabsTab({ value, children }) {
const { activeValue, setActiveValue, rootId } = useTabsContext();
const encodedValue = encodeURIComponent(String(value));
const tabId = `${rootId}-tab-${encodedValue}`;
const panelId = `${rootId}-panel-${encodedValue}`;
const isActive = activeValue === value;
return (
<button
id={tabId}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
onClick={() => setActiveValue(value)}
>
{children}
</button>
);
}
function TabsPanel({ value, children }) {
const { activeValue, rootId } = useTabsContext();
const encodedValue = encodeURIComponent(String(value));
const tabId = `${rootId}-tab-${encodedValue}`;
const panelId = `${rootId}-panel-${encodedValue}`;
return (
<div
id={panelId}
role="tabpanel"
aria-labelledby={tabId}
hidden={activeValue !== value}
tabIndex={0}
>
{children}
</div>
);
}
Tabs.List = TabsList;
Tabs.Tab = TabsTab;
Tabs.Panel = TabsPanel;
function ControlledExample() {
const [activeTab, setActiveTab] = useState("profile");
return (
<>
<p>Активна вкладка: {activeTab}</p>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="profile">Профіль</Tabs.Tab>
<Tabs.Tab value="security">Безпека</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile">
Дані профілю користувача
</Tabs.Panel>
<Tabs.Panel value="security">
Налаштування безпеки
</Tabs.Panel>
</Tabs>
</>
);
}
function UncontrolledExample() {
return (
<Tabs defaultValue="details">
<Tabs.List>
<Tabs.Tab value="details">Деталі</Tabs.Tab>
<Tabs.Tab value="history">Історія</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="details">
Детальна інформація
</Tabs.Panel>
<Tabs.Panel value="history">
Історія змін
</Tabs.Panel>
</Tabs>
);
}
function App() {
return (
<>
<h1>Керовані вкладки</h1>
<ControlledExample />
<h1>Некеровані вкладки</h1>
<UncontrolledExample />
</>
);
}
createRoot(document.getElementById("root")).render(<App />);API відображає структуру інтерфейсу;
компоненти не потребують великої кількості взаємопов’язаних props;
внутрішній стан не потрібно передавати вручну через кілька рівнів;
компоненти можна комбінувати в межах дозволеної моделі.
Compound components не варто застосовувати до кожного набору компонентів. Якщо елементи не мають спільної поведінки або стану, звичайна композиція через children буде простішою.
Також потрібно явно перевіряти використання дочірніх компонентів поза батьківським компонентом. Помилка через відсутній контекст має бути зрозумілою для розробника.
Керований компонент отримує поточне значення через props, а зміни повідомляє через callback:
function SearchInput({ value, onChange }) {
return (
<input
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}Батьківський компонент володіє станом:
function SearchPage() {
const [query, setQuery] = useState("");
return <SearchInput value={query} onChange={setQuery} />;
}Некерований компонент володіє станом самостійно:
function SearchInput({ defaultValue = "" }) {
const [query, setQuery] = useState(defaultValue);
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}Керований компонент потрібен, коли:
значення використовується за межами компонента;
батьківський компонент повинен програмно змінювати його;
кілька компонентів залежать від одного значення;
стан потрібно синхронізувати з URL, формою або серверними даними;
потрібна централізована валідація.
Некерований режим доречний, коли:
стан є локальною деталлю компонента;
батьківському компоненту не потрібне кожне оновлення;
компонент має працювати з мінімальною кількістю props;
потрібно зручно використовувати компонент у простих сценаріях.
Багато бібліотечних компонентів підтримують обидва режими через пару props:
value — кероване значення;
defaultValue — початкове значення для некерованого режиму;
onChange — повідомлення про зміни.
Не слід перемикати компонент між режимами після першого рендерингу. Наприклад, спочатку передавати value={undefined}, а потім передати конкретне значення. Це створює непередбачувану поведінку.
Custom Hook інкапсулює поведінку, яка може використовуватися в кількох компонентах. Він не обов’язково має повертати JSX.
Наприклад, логіку керування відкритим станом можна винести окремо:
import { useCallback, useState } from "react";
function useDisclosure(initialOpen = false) {
const [isOpen, setIsOpen] = useState(initialOpen);
const open = useCallback(() => {
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
}, []);
const toggle = useCallback(() => {
setIsOpen((current) => !current);
}, []);
return {
isOpen,
open,
close,
toggle,
};
}
function UserMenu() {
const menu = useDisclosure();
return (
<div>
<button type="button" onClick={menu.toggle}>
Меню
</button>
{menu.isOpen && (
<div>
<button type="button" onClick={menu.close}>
Закрити
</button>
<p>Пункти меню</p>
</div>
)}
</div>
);
}Custom Hook повинен відповідати за логіку, але не за конкретну розмітку. Завдяки цьому одна й та сама поведінка може використовуватися в меню, діалозі або панелі фільтрів.
Не слід створювати Hook лише для того, щоб сховати кілька рядків JSX. Це ускладнює пошук логіки.
Також не потрібно об’єднувати в один Hook несумісні обов’язки. Наприклад, завантаження даних, керування модальним вікном і форматування дат краще представляти окремими Hooks.
Коли стан складається з кількох пов’язаних значень, послідовність оновлень краще описати через reducer.
Reducer — це чиста функція, яка отримує поточний стан і подію та повертає новий стан:
function formReducer(state, action) {
switch (action.type) {
case "fieldChanged":
return {
...state,
fields: {
...state.fields,
[action.field]: action.value,
},
errors: {
...state.errors,
[action.field]: undefined,
},
};
case "submitted":
return {
...state,
isSubmitting: true,
};
case "submitSucceeded":
return {
...state,
isSubmitting: false,
isSubmitted: true,
};
case "submitFailed":
return {
...state,
isSubmitting: false,
submitError: action.error,
};
default:
throw new Error(`Невідома дія: ${action.type}`);
}
}Компонент повідомляє про подію:
dispatch({
type: "fieldChanged",
field: "email",
value: event.target.value,
});Переваги такого підходу:
переходи стану описані централізовано;
складну логіку простіше тестувати без рендерингу компонента;
назви дій пояснюють, що відбулося;
зменшується кількість незалежних викликів setState.
Reducer особливо корисний для:
багатокрокових форм;
асинхронних операцій із кількома статусами;
редакторів;
кошика або фільтрів;
компонентів зі складними переходами стану.
Не варто переносити до reducer кожен простий boolean. Для стану на кшталт isOpen звичайний useState зазвичай зрозуміліший.
State reducer pattern розширює reducer так, щоб батьківський компонент міг впливати на переходи стану.
Компонент має внутрішню поведінку за замовчуванням, але приймає stateReducer:
function defaultStateReducer(state, action) {
switch (action.type) {
case "toggle":
return {
...state,
isOpen: !state.isOpen,
};
default:
return state;
}
}
function Dropdown({ stateReducer = defaultStateReducer }) {
const [state, setState] = useState({ isOpen: false });
function dispatch(action) {
setState((currentState) =>
stateReducer(stateReducer(currentState, action), action)
);
}
return (
<button
type="button"
onClick={() => dispatch({ type: "toggle" })}
>
{state.isOpen ? "Закрити" : "Відкрити"}
</button>
);
}У практичній реалізації reducer потрібно викликати лише один раз із поточним станом і дією. Наприклад:
function dispatch(action) {
setState((currentState) =>
stateReducer(currentState, action)
);
}Батьківський компонент може заборонити певний перехід:
function preventOpening(state, action) {
if (action.type === "toggle" && !state.isOpen) {
return state;
}
return defaultStateReducer(state, action);
}Цей патерн корисний для бібліотечних компонентів, яким потрібні розширювані правила поведінки. Для звичайного компонента він часто є надмірним.
Цей патерн розділяє:
отримання даних і керування станом;
відображення інтерфейсу.
Presentational-компонент отримує готові дані та callback-и:
function UserList({ users, onSelect }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<button type="button" onClick={() => onSelect(user.id)}>
{user.name}
</button>
</li>
))}
</ul>
);
}Container-компонент підключає дані:
function UserListContainer() {
const [selectedId, setSelectedId] = useState(null);
const users = [
{ id: 1, name: "Олена" },
{ id: 2, name: "Марко" },
];
return (
<>
<UserList users={users} onSelect={setSelectedId} />
<p>Вибраний користувач: {selectedId ?? "немає"}</p>
</>
);
}У сучасному React цей поділ не обов’язково означає створення окремого компонента-контейнера. Часто логіку можна винести в Custom Hook, а компонент залишити єдиним:
function useUsers() {
const users = [
{ id: 1, name: "Олена" },
{ id: 2, name: "Марко" },
];
return { users };
}
function UsersPage() {
const { users } = useUsers();
const [selectedId, setSelectedId] = useState(null);
return (
<>
<UserList users={users} onSelect={setSelectedId} />
<p>Вибраний користувач: {selectedId ?? "немає"}</p>
</>
);
}Суть патерну не в назвах файлів, а в чіткому розподілі відповідальності.
Context зручний, коли значення потрібно передати багатьом компонентам без проміжних props:
тема;
локаль;
дані автентифікованого користувача;
стан складного compound component.
Проте Context не є автоматичною заміною state management бібліотеці. Коли значення провайдера змінюється, споживачі цього контексту можуть повторно рендеритися.
Щоб зменшити зв’язність:
створюйте окремі контексти для незалежних значень;
не передавайте в Context об’єкт, який змінюється на кожному рендері без потреби;
тримайте Provider якомога ближче до компонентів, які його використовують;
не розміщуйте весь стан застосунку в одному Context.
Наприклад, стан теми та стан сесії краще представляти різними провайдерами, якщо вони змінюються незалежно.
Поставте такі запитання:
Чи має компонент кілька взаємопов’язаних частин?
Використайте compound components.
Чи потрібен батьківському компоненту контроль над значенням?
Зробіть компонент керованим або підтримайте обидва режими.
Чи повторюється поведінка в різних компонентах?
Винесіть її в Custom Hook.
Чи має стан багато переходів і пов’язаних полів?
Розгляньте useReducer.
Чи потрібен споживачам доступ до спільного стану без props drilling?
Використайте локальний Context із чіткою межею відповідальності.
Чи є компонент лише відображенням даних?
Відокремте його від логіки отримання даних і керування станом.
Найкраща архітектура — не та, у якій використано найбільше патернів, а та, у якій відповідальність компонентів легко пояснити.
Не потрібно створювати Context для кожного стану. Для локального стану достатньо useState, а для складних переходів — useReducer.
Компонент не повинен іноді використовувати value, а іноді внутрішній стан для одного й того самого екземпляра. Визначте режим на початку його життєвого циклу.
Компонент із десятками props, прапорців і спеціальних винятків часто приховує кілька різних відповідальностей. Краще використати композицію або кілька менших компонентів.
Якщо значення потрібне лише проміжному компоненту, не слід одразу додавати Context. Спочатку перевірте, чи можна передати callback або використати композицію.
Відображення, запити, трансформація даних і складні переходи стану в одному компоненті ускладнюють тестування. Виносьте повторювану або незалежну логіку в Hooks чи reducer.
Hook має приховувати стабільну, зрозумілу поведінку. Якщо він лише перейменовує один useState, абстракція може не мати користі.
Compound components повинні генерувати семантично коректну розмітку. Для вкладок потрібні відповідні ролі та ARIA-зв’язки, для кнопок — справжні елементи <button>, а не довільні <div> з обробником кліку.
React-компоненти краще будувати через композицію, а не успадкування.
Compound components об’єднують кілька компонентів спільним станом і декларативним API.
Controlled components передають контроль над станом батьківському компоненту, а uncontrolled зберігають стан локально.
Custom Hooks повторно використовують поведінку без прив’язки до конкретної розмітки.
useReducer спрощує керування складними переходами стану.
State reducer pattern дозволяє користувачеві компонента змінювати правила переходів.
Context потрібно обмежувати чіткою відповідальністю та не перетворювати на універсальне глобальне сховище.
Патерн слід обирати після аналізу відповідальності компонента, а не використовувати заради самої абстракції.