Composable Components w React

🗓️

6 min. czytania

Wstę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.

⛔ Zły Kod: Monolityczna Konfiguracja
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?

  1. Łamanie zasady Open/Closed (SOLID): Każda zmiana w wyglądzie wymaga modyfikacji kodu źródłowego komponentu.
  2. Sztywność: Co jeśli w nowym widoku chcemy umieścić status nad name? Wymaga to kolejnego propa statusPosition lub przepisania komponentu.
  3. Nieczytelne API: Deweloper używający tego komponentu musi zgadywać, które kombinacje propsów działają ze sobą (np. czy isEditable działa w wariancie compact?).

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.

✅ Dobry Kod: Composable Components
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:

  1. Separation of Concerns: Komponent Profile odpowiada za kontener, a widok nadrzędny odpowiada za układ treści.
  2. Mniej bugów regresyjnych: Zmiana wyglądu profilu w panelu admina nie zepsuje wyglądu profilu w sidebarze, ponieważ są one komponowane niezależnie.
  3. Łatwiejsze testowanie: Testujemy mniejsze, wyizolowane jednostki zamiast jednego gigantycznego bloku logiki.
  4. Brak “Props Explosion”: API komponentu jest czyste i przewidywalne.
  5. 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.

✅ Inteligentna Kompozycja z Sortowaniem Slotów
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.