Вирішення проблеми перевантаження пропсами у React за допомогою композиції та слотів
Дізнайтеся, чому React-пропи з великою кількістю налаштувань спричиняють проблеми з технічним обслуговуванням, та як інверсія керування, композиція та слоти дозволяють створювати справді повторно використовувані компоненти.
Розгляд того, як властивості, зумовлені конфігурацією, поступово створюють проблеми з технічним обслуговуванням, а також як інверсія керування разом із композицією призводять до справді модульних компонентів інтерфейсу.
"Універсальною таблицею даних."
<UniversalDataTable
data={users}
columns={columns}
enablePagination={true}
paginationType="cursor"
enableSorting={true}
showFilterRow={true}
hasExportToCsvButton={true}
exportButtonText="Download CSV"
headerBackground="light-gray"
denseRows={false}
enableRowSelection={true}
selectionMode="multiple"
renderCustomRowActions={(row) => <DropdownMenu row={row} />}
onRowClick={(row) => navigate(`/users/${row.id}`)}
/>
Під капотом знаходиться приблизно 1 200 рядків логіки.
Хтось береться за <UniversalDataTable />, сподіваючись, що він просто буде функціонувати.
Саме тоді все й ламається.
Немає можливості налаштувати межі окремих клітинок.
Вирівнювання заголовків жорстко закодоване у певному форматі flex layout.
Щоб отримати чистий результат, доводиться використовувати hasExportToCsvButton={false} та кілька інших параметрів для перевизначення налаштувань.
Тож команда додає ще чотири параметри:
isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles
Кількість рядків у файлі зростає до 1,450.
На цьому етапі його взагалі неможливо повторно використати.
Те, що спочатку було абстракцією, перетворилося на клубок спеціальних випадків, які претендують на те, що є одним компонентом.
1. Пастка параметрів конфігурації
Коли команда хоче, щоб компонент був „повторно використовуваним“, зазвичай інстинктивним кроком є конфігурування через параметри.
Кожен новий випадок використання призводить до додавання ще одного флага, переліку рядків, функції-відповіді чи перевизначення стилю до інтерфейсу props.
Типовий приклад:
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
hasLeftIcon?: boolean;
leftIcon?: React.ReactNode;
hasRightIcon?: boolean;
rightIcon?: React.ReactNode;
isFullWidth?: boolean;
tooltipText?: string;
tooltipPosition?: 'top' | 'bottom';
rounded?: boolean;
}
На перший погляд це здається адаптивним.
Проблема полягає у тому, що гнучкість, заснована на конфігурації, має витрати, які не видно одразу.
1. Комбінаторний вибух
Лише десять булевих параметрів теоретично дають 1 024 можливі комбінації.
Вам не потрібно тестувати їх усі, щоб зрозуміти, наскільки швидко ситуація виходить з-під контролю.
Деякі комбінації суперечать одна одній.
Інші просто залишатимуться невикористаними назавжди.
Деякі будуть відображатися по-різному залежно від ширини вікна перегляду, оточуючого контенту чи глибини вкладення компонента.
З часом у результаті отримується купа крайніх випадків замість послідовного, прогнозованого API.
2. Проникнення через параметри
Уявіть, що налаштування підказки має досягти вкладеного обгортку, розташованого на кількох рівнях глибоко всередині дерева компонентів.
Кожен проміжний компонент на шляху тепер мусить передавати параметр, який йому насправді не потрібен.
Конфігурація проходить крізь всі рівні лише тому, що це вимагає структура, а не тому, що це має сенс там.
Ось який механізм лежить в основі процесу проникнення параметрів.
3. Жорсткі припущення щодо макетування
Саме тут компоненти, засновані на конфігурації, зазвичай остаточно перестають функціонувати.
Припустимо, новий дизайн вимагає розміщення іконки між двома рядками тексту кнопок.
Додавання чогось на кшталт:
iconPosition="between"
може тимчасово виправити ситуацію.
Але що буде, як дизайн знову зміниться?
Незабаром вам доведеться підтримувати цілу низку варіантів:
iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"
Список продовжує розширюватися, тому що внутрішній JSX компонента все ще безумовно визначає його макет.
Справжня проблема не в кількості наявних параметрів.
Проблема в тому, що компонент бере на себе занадто багато обов’язків щодо речей, які не належать до його сфери компетенції.
2. Інверсія керування: краща ментальна модель
Справжня повторюваність не досягається шляхом спроби заздалегідь передбачити кожну майбутню вимогу.
Вона походить від застосування Інверсії керування (IoC).
Концепція є простою:
Замість того, щоб компонент визначав усе, що відображається всередині нього, нехай той, хто використовує компонент, керує тими частинами, які потребують змін.
Саме тому композиція є настільки сильним паттерном у React.
Замість створення кнопки, яка вбудовано підтримує підказки, іконки, спінери, значки та будь-які варіанти макетування, створіть кнопку, основною функцією якої є просто бути кнопкою.
Наприклад:
export function Button({
children,
variant = 'primary',
className = '',
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} ${className}`}
{...props}
>
{children}
</button>
);
}
Контроль над вмістом тепер знаходиться у користувача:
<Tooltip content="Save your profile">
<Button>
<SaveIcon />
<span>Save Changes</span>
</Button>
</Tooltip>
Зверніть увагу, що тепер це не потрібно.
Кнопка зовсім не знає про існування підказок.
Немає властивості hasLeftIcon.
Немає властивості leftIcon.
Немає властивості iconSpacing.
Немає властивості hasSpinner.
Потрібен спінер завантаження? Просто розмістіть його безпосередньо всередині кнопки.
<Button>
<Spinner />
Saving...
</Button>
Розмір компонента зменшується, водночас збільшується його гнучкість.
Саме цей компроміс є ключовим аспектом тут.
Багаторазовий компонент не повинен внутрішньо обробляти кожен можливий сценарій. Йому потрібні чітко визначені межі, за якими можуть будуватися інші компоненти.
3. Слоти полегшують розширення складних компонентів
Використання children добре підходить для простих елементів. Але що робити з більш складними елементами, такими як картки, діалогові вікна, панелі керування чи панелі профілю?
Саме тут слоти та складні компоненти виявляють свою корисність.
<UserProfileCard
avatarUrl="/avatar.jpg"
name="Sarah Connor"
role="Tech Lead"
status="Active"
bio="Building reliable frontend systems."
primaryActionLabel="Send Message"
onPrimaryAction={sendMessage}
secondaryActionLabel="View Profile"
onSecondaryAction={viewProfile}
badgeColor="green"
/>
На перший погляд це здається зручним.
Але потім вимоги починають змінюватися.
Команда з маркетингу хоче, щоб соціальний профіль особи відображався прямо поруч із її ім’ям. Команда адміністраторів для корпоративних облікових записів потребує банера з написом „Suspended“, розтягнутого по всій ширині картки. Інша команда, яка працює над продуктом, хоче три кнопки дій замість двох. Дизайнер вирішує, що аватар має розташовуватися з правого боку, а не з лівого.
Кожна з цих вимог перетворюється на ще один елемент дизайну.
Кращим підходом є представлення структури у вигляді окремих складових елементів:
<Card>
<Card.Header>
<Avatar
src={user.avatarUrl}
alt={user.name}
/>
<div>
<Card.Title>{user.name}</Card.Title>
<Card.Subtitle>{user.role}</Card.Subtitle>
</div> <Badge variant={user.statusColor}>
{user.status}
</Badge>
</Card.Header> <Card.Body>
<p>{user.bio}</p>
</Card.Body> <Card.Actions>
<Button
variant="secondary"
onClick={viewProfile}
>
View Profile
</Button> <Button
variant="primary"
onClick={sendMessage}
>
Send Message
</Button>
</Card.Actions>
</Card>
Тепер ви можете бачити структуру картки у JSX. А оскільки ви її бачите, ви можете її змінити.
Хочете ще одну кнопку дій? Додайте її. Потрібен інший бейдж? Замініть його. Треба додати ще один банер? Вставте його там, де це потрібно. Бажаєте зовсім іншого розташування заголовка? Просто напишіть новий заголовок.
Жоден із цих підходів не вимагає змінювати надмірно складний спільний компонент для підтримки одного нового варіанту.
4. Композиція краща за конфігурацію
Ці два підходи ставлять принципово різні запитання.
Конфігурація запитує:
„Який набір опцій має надавати цей компонент?“
Композиція запитує:
„Які невеликі елементи можуть користувачі поєднати для отримання того, що їм насправді потрібно?“
Конфігурація постійно покладає все більше відповідальності за прийняття рішень на сам компонент. Композиція повертає ці рішення тим, хто його використовує. Зазвичай це краще місце для такої відповідальності.
Візьміть це порівняння як приклад:
<Button
hasLeftIcon
leftIcon={<SaveIcon />}
hasTooltip
tooltipText="Save your profile"
showSpinner={isSaving}
spinnerPosition="left"
/>
проти:
<Tooltip content="Save your profile">
<Button>
{isSaving && <Spinner />}
<SaveIcon />
Save Profile
</Button>
</Tooltip>
У другому фрагменті справді більше JSX. Але в ньому також значно менше зв’язків між частинами, які не повинні знати одна про одну. Ця заміна зазвичай варта уваги — трохи додаткового маркапу в місці виклику здебільшого є дешевшим у довгостроковій перспективі, ніж додавати ще одну постійну функцію до спільного компонента.
5. Навчайтеся у безголових бібліотек
Кілька сучасних фронтенд-бібліотек чудово ілюструють цю філософію.
Проекти на кшталт Radix UI, TanStack Table та shadcn/ui відіграли важливу роль у популяризації цього підходу, який ставить композицію на перше місце.
TanStack Table є одним із найяскравіших прикладів. Він не примушує вас дотримуватися однієї фіксованої структури таблиці. Натомість він надає логіку та механізми керування станом для таких завдань, як:
- Сортування
- Фільтрація
- Сторінкування
- Вибір рядка
Перетворення цього стану на фактичний маркап залежить виключно від вас. Наприклад:
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{/* Your own rendering logic */}
</tbody>
</table>
Точна структура API насправді не є ключовою тут. Важлива основна архітектура: двигун таблиць визначає поведінку, тоді як ви відповідаєте за візуальне представлення. Оскільки ці два аспекти розділені, ви можете вільно переробити візуальний формат, не змушуючи основну логіку таблиці перетворюватися на один величезний візуальний компонент.
6. Розділення поведінки та зовнішнього вигляду
Це природно приводить до ще одного правила, яке варто дотримуватися:
Тримайте те, що робить компонент, окремо від способу його візуалізації.
Один компонент може законно містити значну кількість внутрішньої складності, яка охоплює такі аспекти, як:
- Навігація за допомогою клавіатури
- Керування фокусом
- Вибір
- Сортування
- Фільтрація
- Стан
- Доступність
- Позиціонування
Ефективне керуванням усім цим рідко є простим завданням. Проте жоден з цих аспектів не змушує один і той самий компонент визначати точний вигляд кінцевого результату.
Саме це є основною ідеєю більшості безголових систем користувацького інтерфейсу. Їхня поведінка може використовуватися у багатьох візуальних реалізаціях, тоді як шар представлення залишається абсолютно відкритим. Такий підхід зазвичай забезпечує значно кращу можливість повторного використання, ніж спроба створити один величезний компонент, наповнений десятками налаштувань візуального форматування.
7. Коли props справді мають сенс
Усе це не означає, що props — це поганий підхід.
Це один із найфундаментальніших інструментів, які надає React.
Проблема не в тому, що існують props. Проблема у використанні їх для кодування кожної можливої структурної варіації, яка може знадобитися компоненту.
Приклади на кшталт цих абсолютно нормальні:
<Button variant="primary" />
<Button size="sm" />
<Input disabled />
<Card className="featured" />
Червоний прапор з’являється тоді, коли компонент починає накопичувати список у такому форматі:
<Component
showHeader
showFooter
showBadge
showIcon
showActions
iconPosition="left"
badgePosition="right"
footerAlignment="center"
actionLayout="horizontal"
compact
dense
bordered
rounded
disableAnimation
customHeaderRenderer={...}
customFooterRenderer={...}
/>
Як тільки ви помітили таку закономірність, варто зупинитися та поставити собі запитання:
"Чи справді вони мають залишатися як пропси, чи краще деякі з них будуть функціонувати як складові дочерні елементи?"
Просте поставлення цього запитання зазвичай допомагає уникнути багатьох зайвих складнощів у майбутньому.
8. Що насправді коштує вам надмірна абстракція
Справжня шкода від надмірно налаштованих компонентів не є естетичною.
Проблема в тому, що їх стає складно модифікувати.
Широко використовуваний компонент має великий радіус впливу. Якщо двадцять місць у вашому кодбазі залежать від нього, то кожен новий пропс, який ви додасте, може вплинути на всі ці двадцять компонентів.
Інженери починають вагатися, чи варто його торкатися.
Написання документації до нього стає складнішим.
Тестування його стає складнішим.
Виявлення помилок у ньому стає складнішим.
Зрештою компонент стає настільки повністю „повторно використовуваним“, що ніхто насправді не хоче його використовувати знову.
Ось іронія, вбудована в цю модель.
Надмірна абстракція може підірвати саме ту повторну використовуваність, яку вона мала забезпечити.
Невеликий, чітко визначений примітив часто виявляється більш придатним для повторного використання на практиці, ніж об’ємний компонент, створений для передбачення кожної можливої варіації.
Практична схема для створення компонентів
Перш ніж вдаватися до іншої конфігураційної властивості, пройдіться цими перевірками.
1. Чи маю я справу з поведінкою чи з візуалізацією?
Якщо це пов’язано з поведінкою, то проп або кастомний хук, ймовірно, є правильним інструментом. Якщо ж йдеться виключно про візуальну структуру, то композиція зазвичай є кращим рішенням.
2. Чи справді цьому компоненту потрібна ця інформація?
Якщо від одного компонента очікується, що він розумітиме підказки, хуки аналітики, значки, іконки, індикатори завантаження та будь-яку комбінацію своїх дочірніх елементів, це майже напевно означає, що він бере на себе більше обов’язків, ніж слід.
3. Чи міг би замість цього використовуватися children?
Якщо так, краще використовувати композицію, ніж створювати ще один проп для форматування контенту.
4. Чи допоможуть названі слоти уточнити API?
Для більш складних компонентів визначення чітких областей, наприклад:
<Card.Header />
<Card.Body />
<Card.Footer />
може значно полегшити розуміння їх структури.
5. Чи можна відокремити логіку від маркапу?
Якщо так, варто розглянути підхід без головного елемента або підхід, що враховує логіку насамперед.
6. Чи вирішує цей елемент реальну потребу чи уявну?
Це питання має більше значення, ніж здається.
Утримуйтеся від додавання чогось на кшталт:
futureFeature={true}
лише тому, що хтось може захотіти цього у майбутньому.
Проектуйте з урахуванням вимог, щодо яких у вас є реальні докази, а не тих, про які ви лише здогадуєтесь.
Заключні міркування
Мета створення повторно використовуваних компонентів React — не створювати щось, що може робити абсолютно все.
Мета — створювати елементи, які можна складати багатьма різними способами.
Ця різниця здається тонкою, але з архітектурної точки зору вона змінює все.
Компонент, який використовує тридцять параметрів, може здаватися гнучким на перший погляд, але кожен з цих параметрів додає ще одну відповідальність, ще один стан, який потрібно враховувати, та ще одну комбінацію, яку рано чи пізно доведеться підтримувати.
Навпаки, компонент із вузьким API та чіткими межами складання може здаватися простим.
Однак на практиці він зазвичай краще тримається з часом.
Тож наступного разу, коли виникне бажання додати ще один параметр до спільного компонента, зупиніться на мить та запитайте себе:
„Чи роблю я його більш повторно використовуваним, чи просто більш налаштовуваним?“
Ці два аспекти не є взаємозамінними.
Компонент не стає повторно використовуваним через накопичення тридцяти опцій.
Він стає повторно використовуваним, добре виконуючи одну функцію, маючи здорові межі та не заважаючи в інших аспектах.
Архітектура монолітичних компонентів не полягає у прогнозуванні кожної майбутньої потреби.
Вона полягає у тому, щоб зробити будь-які майбутні потреби легко інтегруваними.
Пов’язані статті
- Створення стійкої сторінки деталей фільму за допомогою Next.js App Router — Дізнайтеся, як правильно отримувати та кешувати дані API OMDB у Next.js App Router за допомогою асинхронних серверних компонентів, параметрів await та належного оброблення помилок 404.
- Непомітні переваги TypeScript 6 та звички розробників високого рівня у JavaScript — Дізнайтеся про недооцінені функції TypeScript 6, такі як пряме керування ресурсами та параметри типу const, а також про ідіоми JavaScript, від яких щодня залежать старші інженери.