Composable Components w React
🗓️
6 min. czytaniaWstęp
W inżynierii frontendowej często wpadamy w pułapkę nadmiernej abstrakcji. Zaczynamy od prostego komponentu, który ma spełniać jedną funkcję. Jednak wraz z rozwojem wymagań biznesowych, ten sam komponent zaczyna obrastać w kolejne warunki logiczne, flagi i propsy konfiguracyjne. To zjawisko, znane jako “Prop Drilling” lub “Props Explosion”, jest jednym z głównych źródeł długu technicznego w projektach opartych na React.
Choć często występują razem, Prop Drilling dotyczy głębokości przekazywania danych, a Props Explosion – rozrostu API komponentu.
W tym artykule przeanalizujemy, jak przejście z modelu konfiguracyjnego na model kompozycyjny (Composable Components) pozwala na tworzenie bardziej skalowalnego i utrzymywalnego kodu.
Problem: Podejście Konfiguracyjne (Anti-Pattern)
Spójrzmy na typowy scenariusz. Mamy komponent UserProfile, który pierwotnie wyświetlał tylko awatar i imię. Z czasem pojawiły się wymagania: “dodaj status”, “dodaj przycisk edycji”, “w innej widoku ukryj opis”.
Podejście naiwne polega na przekazywaniu wszystkich danych i instrukcji sterujących bezpośrednio do komponentu nadrzędnego.
interface UserProfileProps {
name: string;
avatarUrl: string;
role: string;
description?: string;
// Zaczyna się bałagan:
showStatus?: boolean;
statusText?: string;
isEditable?: boolean;
onEdit?: () => void;
renderCustomActions?: React.ReactNode; // Desperacka próba ratunku
variant?: 'compact' | 'full' | 'sidebar';
}
const UserProfile = ({
name,
avatarUrl,
variant = 'full',
showStatus,
/* ...reszta z 10 propsów */
}: UserProfileProps) => {
// Wewnątrz mamy "spaghetti code" pełen instrukcji warunkowych
return (
<div className={`profile ${variant}`}>
<img src={avatarUrl} />
<div className="info">
<h3>{name}</h3>
{/* Logika biznesowa "zaszyta" w warstwie prezentacji */}
{variant !== 'compact' && <p>{role}</p>}
{showStatus && <span className="status">{status}</span>}
{/* Trudno modyfikować układ bez ingerencji w ten plik */}
</div>
</div>
);
};Dlaczego to podejście jest ryzykowne?
- Łamanie zasady Open/Closed (SOLID): Każda zmiana w wyglądzie wymaga modyfikacji kodu źródłowego komponentu.
- Sztywność: Co jeśli w nowym widoku chcemy umieścić
statusnadname? Wymaga to kolejnego propastatusPositionlub przepisania komponentu. - Nieczytelne API: Deweloper używający tego komponentu musi zgadywać, które kombinacje propsów działają ze sobą (np. czy
isEditabledziała w warianciecompact?).
Rozwiązanie: Composable Components (Wzorzec)
Rozwiązaniem jest zastosowanie Inversion of Control (Odwrócenie Sterowania). Zamiast kazać komponentowi zgadywać, co i jak ma wyświetlić na podstawie flag, przekazujemy mu gotowe fragmenty UI jako dzieci (children).
Stosujemy tutaj wzorzec Compound Components, który pozwala na zachowanie spójności logicznej przy jednoczesnej elastyczności wizualnej.
import React from "react";
interface Props {
children: React.ReactNode;
className?: string;
}
interface ChildrenOnly {
children: React.ReactNode;
}
// 1. Tworzymy reużywalne pod-komponenty, które mają jedną odpowiedzialność
const ProfileRoot = ({ children, className }: Props) => (
<div className={`flex gap-4 p-4 border rounded ${className}`}>{children}</div>
);
const ProfileAvatar = ({ src }: { src: string }) => (
<img src={src} className="w-12 h-12 rounded-full" />
);
const ProfileInfo = ({ children }: ChildrenOnly) => (
<div className="flex flex-col justify-center">{children}</div>
);
const ProfileStatus = ({ isActive }: { isActive: boolean }) => (
<span className={`w-3 h-3 rounded-full ${isActive ? 'bg-green-500' : 'bg-gray-300'}`} />
);
// 2. Eksponujemy je jako namespace (opcjonalnie)
export const Profile = Object.assign(ProfileRoot, {
Avatar: ProfileAvatar,
Info: ProfileInfo,
Status: ProfileStatus,
Name: ({ children }: ChildrenOnly) => <h3 className="font-bold text-lg">{children}</h3>,
Role: ({ children }: ChildrenOnly) => <p className="text-sm text-gray-500">{children}</p>,
});
// --- UŻYCIE (Implementation Details) ---
// Przypadek A: Pełny profil w Dashboardzie
<Profile>
<Profile.Avatar src="/user.jpg" />
<Profile.Info>
<div className="flex items-center gap-2">
<Profile.Name>Jan Kowalski</Profile.Name>
<Profile.Status isActive={true} />
</div>
<Profile.Role>Senior Engineer</Profile.Role>
</Profile.Info>
</Profile>
// Przypadek B: Sidebar (inny układ, te same komponenty)
<Profile className="bg-gray-100">
{/* Zmieniliśmy kolejność, usunęliśmy rolę, dodaliśmy customowy guzik - ZERO zmian w bibliotece */}
<Profile.Info>
<Profile.Name>Jan Kowalski</Profile.Name>
<button className="text-blue-500 text-xs">Wyloguj</button>
</Profile.Info>
<Profile.Avatar src="/user.jpg" />
</Profile>Kluczowe korzyści biznesowe i techniczne:
- Separation of Concerns: Komponent
Profileodpowiada za kontener, a widok nadrzędny odpowiada za układ treści. - Mniej bugów regresyjnych: Zmiana wyglądu profilu w panelu admina nie zepsuje wyglądu profilu w sidebarze, ponieważ są one komponowane niezależnie.
- Łatwiejsze testowanie: Testujemy mniejsze, wyizolowane jednostki zamiast jednego gigantycznego bloku logiki.
- Brak “Props Explosion”: API komponentu jest czyste i przewidywalne.
- Elastyczność: Rozbudowa o nowe funkcjonalności (np. dodanie
Profile.Badge) nie wymaga modyfikacji istniejącego kodu, co zwiększa skalowalność i utrzymanie projektu.
Kiedy nie stosować Composable Components?
Choć wzorzec jest elastyczny, nie zawsze jest najlepszy. W prostych komponentach jednorazowych lub w mocno restrykcyjnych systemach UI dodatkowa kompozycja może być over-engineeringiem. Również w komponentach krytycznych wydajnościowo (np. tabele renderowane tysiące razy) klasyczne propsy mogą być szybsze i prostsze w utrzymaniu.
Compound Components a React Context
W naszym przykładzie Compound Components działają tylko przez children, bez Contextu. W bardziej złożonych przypadkach (np. Tabs, Accordion) Context pozwala współdzielić stan między podkomponentami i kontrolować logikę. Tu celowo uprościliśmy przykład, skupiając się wyłącznie na elastycznej strukturze UI.
Czyli kiedy używać którego podejścia?
✅ Kiedy warto:
- Komponenty są używane w różnych miejscach i układach.
- Chcemy uniknąć „props explosion” i ułatwić utrzymanie.
- Zależy nam na elastyczności i łatwej rozbudowie w przyszłości.
❌ Kiedy nie warto:
- Komponent jest prosty, jednorazowy lub bardzo specyficzny.
- Design system wymaga sztywnej struktury i spójności wizualnej.
- Liczy się maksymalna wydajność w dużych listach lub tabelach.
Zasada praktyczna: stosuj Composable Components tam, gdzie elastyczność i utrzymanie są ważniejsze niż minimalna ilość kodu lub mikro-optimalizacja wydajności.
Czym różni się to od zwykłego przekazywania komponentów jako propsów?
Przekazywanie komponentów jako propsów (np. renderCustomActions, renderFooter, renderXYZ) jest krokiem w kierunku kompozycji, ale nadal utrzymuje kontrolę nad strukturą w komponencie nadrzędnym. Composable Components idą o krok dalej, pozwalając na pełną kontrolę nad strukturą i układem przez użytkownika komponentu, bez konieczności modyfikowania samego komponentu bazowego.
Podsumowanie
Przejście na Composable Components wymaga zmiany myślenia z “konfigurowania” na “budowanie”. Chociaż kod początkowy może wydawać się nieco obszerniejszy, zysk w postaci elastyczności i łatwości utrzymania w długim terminie jest nieoceniony.
Zamiast budować sztywne “szwajcarskie scyzoryki”, budujmy klocki LEGO, z których można stworzyć dowolną strukturę.
Bonus: Inteligentna Kompozycja (Slot-based Rendering)
W niektórych przypadkach możemy chcieć, aby komponent bazowy miał pewne “inteligentne” zachowania dotyczące układu dzieci. Na przykład, możemy chcieć, aby Profile.Avatar zawsze był renderowany po lewej stronie, niezależnie od kolejności w kodzie.
Standardowa kompozycja renderuje dzieci dokładnie tam, gdzie umieścił je programista. Czasami jednak chcemy wymusić spójność wizualną, np. bez względu na to, gdzie w kodzie wrzucimy Profile.Avatar, ma on zawsze wylądować po lewej stronie.
import { Children, isValidElement, type ReactElement } from "react";
// Zakładamy, że ProfileAvatar i ProfileInfo są eksportowane z pliku profile.tsx
import { Profile } from "./profile";
interface Props {
children: React.ReactNode;
};
export const ProfileRoot = ({ children }: Props) => {
const childrenArray = Children.toArray(children).filter(
(child): child is ReactElement => isValidElement(child)
);
// Wyłapujemy konkretne komponenty "po typie"
const avatarSlot = childrenArray.find((child) => child.type === Profile.Avatar);
const infoSlot = childrenArray.find((child) => child.type === Profile.Info);
const actionsSlot = childrenArray.filter((child) => child.type === 'button');
return (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-4">
{/* Avatar zawsze będzie pierwszy, nawet jeśli w JSX daliśmy go na końcu */}
{avatarSlot}
{infoSlot}
</div>
<div className="actions">
{actionsSlot}
</div>
</div>
);
};Ten pattern pozwala na ignorowanie nieznanych dzieci lub wyrzucanie błędów, jeśli wymagane sloty nie są dostarczone. Daje to jeszcze większą kontrolę nad strukturą komponentu, jednocześnie zachowując elastyczność kompozycji.