Що таке HOC (компонент вищого порядку) у React
HOC (Higher-Order Component, компонент вищого порядку) — це функція, яка приймає React-компонент і повертає новий компонент з додатковою логікою, не зачіпаючи оригінал.
Теорія
TL;DR
- HOC = функція, яка бере компонент і повертає покращений
- Аналогія: передаєш звичайний блендер, отримуєш такий, що сам вимірює інгредієнти перед запуском
- Головне: логіка додається через композицію, а не через зміну вихідного коду
- Реальні приклади:
connectу Redux,withRouterу React Router,withStylesу Material-UI - Правило вибору: HOC підходить для класових кодобаз; у новому функціональному коді зазвичай зручніші кастомні хуки
Швидкий приклад
// withLoading: додає спінер без будь-яких змін у UserList
const withLoading = (WrappedComponent) => (props) => {
if (props.isLoading) return <div>Loading...</div>; // перехоплює до рендеру
return <WrappedComponent {...props} />; // передає всі пропси далі
};
const UserList = ({ users }) => (
<ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
);
const UserListWithLoading = withLoading(UserList);
// <UserListWithLoading isLoading={false} users={[{id:1, name:'Alice'}]} />
// Результат: <ul><li>Alice</li></ul>withLoading нічого не знає про UserList. Перевіряє один проп і передає решту далі. Саме в цьому і є сенс.
Головна різниця: композиція проти наслідування
HOC — це композиція. Ти обгортаєш компонент ззовні, зберігаєш його оригінальний рендер і додаєш логіку через замикання (closure). Оригінальний компонент не змінюється. Наслідування, навпаки, лізе всередину класу і модифікує його — це ламається в concurrent mode React і ускладнює відлагодження. З HOC-ами можна стекати шари, знімати їх і тестувати кожен окремо.
Коли використовувати
- Спільне отримання даних у багатьох компонентах -> HOC
withDataFetch - Перевірка авторизації в класовій кодобазі -> HOC; у новому функціональному коді простіше кастомний хук
- Логування або телеметрія під час рендеру -> HOC обгортає сам виклик рендеру без накладних витрат хуків
- Логіка потрібна лише одному компоненту -> не роби HOC, вбудуй її напряму
Як React опрацьовує HOC зсередини
React сприймає повернуту функцію як звичайний компонент. При монтуванні запускає зовнішню функцію HOC, яка створює замикання (closure) над WrappedComponent. Обгортка захоплює пропси у своєму lifecycle і делегує рендер через React.createElement. Жодних спеціальних браузерних API. Це звичайне функціональне програмування, яке виконується під час reconciliation.
Типові помилки
Мутація переданого компонента:
// Неправильно: змінює оригінальний компонент
const badHOC = (WC) => {
WC.prototype.newMethod = () => {}; // забруднює джерело
return WC;
};Це порушує чистоту компонента і ламається в StrictMode, де React двічі монтує компоненти, щоб знайти такі побічні ефекти. Повертай новий компонент — ніколи не модифікуй отриманий.
Пропущений displayName:
// Неправильно: у React DevTools показує "Anonymous"
const withData = (WC) => (props) => <WC {...props} />;// Правильно
const withData = (WC) => {
const Enhanced = (props) => <WC {...props} />;
Enhanced.displayName = `withData(${WC.displayName || WC.name})`;
return Enhanced;
};Без displayName DevTools показує Anonymous^12. Дебажити таке вкрай незручно.
HOC всередині render:
// Неправильно: новий екземпляр HOC при кожному рендері
function App() {
const UserListWithLogger = withLogger(UserList); // ремонтується при кожному рендері
return <UserListWithLogger users={data} />;
}Компонент розмонтовується і монтується заново при кожному рендері, втрачаючи весь локальний стан. Оголошуй HOC на рівні модуля, поза будь-яким компонентом.
Зникнення статичних методів: Коли обгортаєш компонент, статичні методи оригіналу зникають. Копіюй їх вручну:
EnhancedComponent.staticMethod = WrappedComponent.staticMethod;Де зустрічається в реальних проєктах
- Redux:
connect(mapStateToProps)(Component)підписується на стор і передає стан як пропси - React Router v5:
withRouterдодаєlocation,history,matchдо класових компонентів - Material-UI v4:
withStylesіwithThemeприкріплюють CSS-in-JS стилі - Композиція кількох HOC:
withAuth(withData(withLogger(Dashboard)))абоcompose(withAuth, withData, withLogger)(Dashboard)черезflowRightз lodash
Питання на співбесіді
Q: Напиши HOC, який отримує дані і передає їх як проп.
A: Дивись приклад withFetch нижче. Senior-відповідь додає обробку помилок, стан завантаження і AbortController для скасування запиту при демонтажі.
Q: Як компонувати кілька HOC?
A: withAuth(withData(Component)) або compose(withAuth, withData)(Component). Зовнішній HOC запускається першим під час рендеру.
Q: Чому HOC-и вже не такі популярні?
A: Хуки вирішують ті самі задачі без стеку обгорток, колізій пропсів і проблем з передачею ref. На React 16.8+ кастомний хук майже завжди простіший.
Q: HOC проти render props — в чому різниця?
A: HOC автоматично додає пропси і повертає готовий компонент — потік даних неявний. Render props передають дані через функцію в children, де все видно явно. Обидва підходи працюють; render props зручніші, коли потрібен точний контроль над тим, що рендериться.
Q: Як тестувати компонент, обгорнутий у HOC?
A: Тестуй HOC і обгорнутий компонент окремо. Мокай залежності HOC, рендер обгортки — і перевіряй, які пропси були передані. Або виноси логіку у кастомний хук і тестуй хук напряму.
Q: Як реф поводиться в HOC? (Senior)
A: Реф не проходить крізь HOC автоматично, бо ref не є звичайним пропом. Використовуй React.forwardRef всередині HOC. Без нього ref буде null на зовнішній обгортці і будь-який імперативний handle зламається.
Приклади
Базовий: HOC для логування
function withLogger(WrappedComponent) {
function LoggerComponent(props) {
console.log(`[${WrappedComponent.name}] props:`, props); // логує при кожному рендері
return <WrappedComponent {...props} />;
}
LoggerComponent.displayName = `withLogger(${WrappedComponent.name})`;
return LoggerComponent;
}
const Hello = ({ name }) => <h1>Hello, {name}</h1>;
const HelloWithLogger = withLogger(Hello);
// <HelloWithLogger name="Alice" />
// Консоль: [Hello] props: { name: 'Alice' }
// Результат: <h1>Hello, Alice</h1>Простий кейс: без стану, без побічних ефектів. HOC просто обгортає і логує. displayName виставлений — DevTools покаже withLogger(Hello), а не Anonymous.
Середній рівень: HOC для авторизації
import { useContext } from 'react';
import { Navigate } from 'react-router-dom';
const withAuth = (WrappedComponent) => {
function Authenticated(props) {
const { user } = useContext(AuthContext); // хуки всередині HOC працюють з React 16.8+
if (!user) return <Navigate to="/login" />; // редирект без авторизації
return <WrappedComponent {...props} user={user} />; // передаємо user як проп
}
Authenticated.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name})`;
return Authenticated;
};
const Dashboard = ({ user }) => <div>Ласкаво просимо, {user.name}!</div>;
const ProtectedDashboard = withAuth(Dashboard);
// <ProtectedDashboard /> без user -> редирект на /login
// <ProtectedDashboard /> з user -> "Ласкаво просимо, Alice!"Цей патерн схожий на те, що робить react-redux-auth-wrapper зсередини. Dashboard нічого не знає про логіку авторизації — HOC повністю бере це рішення на себе.
Просунутий рівень: HOC для отримання даних з очищенням
import { useState, useEffect } from 'react';
function withFetch(WrappedComponent, url) {
function ComponentWithData(props) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController(); // скасовуємо запит при демонтажі
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch((err) => {
if (err.name !== 'AbortError') setError(err.message);
})
.finally(() => setLoading(false));
return () => controller.abort(); // cleanup при демонтажі
}, []);
if (loading) return <div>Завантаження...</div>;
if (error) return <div>Помилка: {error}</div>;
return <WrappedComponent {...props} data={data} />;
}
ComponentWithData.displayName = `withFetch(${WrappedComponent.displayName || WrappedComponent.name})`;
return ComponentWithData;
}
const UserList = ({ data }) => (
<ul>{data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
);
const UserListWithData = withFetch(UserList, '/api/users');
// <UserListWithData /> -> завантажує /api/users, показує стан завантаження, потім списокAbortController — це те, що відрізняє продакшн HOC від навчального прикладу. Я бачив, як ця помилка викликає memory warnings у дашборд-застосунках з частою навігацією між роутами. Без нього fetch, який завершується після демонтажу, намагається викликати setData на вже знищеному компоненті. Отримуєш попередження і потенційний витік пам'яті.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.