Kurs JavaScript i React · Moduł 15: Wzorce i architektura

Inversion of Control w React - Elastyczne API komponentów

8 min czytania
W tej lekcji8

Komandor Nova chce kartę misji w trzech miejscach: na mostku z przyciskami akcji, w archiwum bez przycisków, ale ze stopką, a w raporcie z odnośnikiem do szczegółów. Pierwsza wersja karty dostaje więc kolejne flagi typu boolean i kolejne warunki w środku:

1// Każdy nowy ekran dokłada flagę i kolejny warunek
2function MissionCard({ mission, showActions, showFooter, compact }) {
3  return (
4    <div className={compact ? 'card card-compact' : 'card'}>
5      <h3>{mission.name}</h3>
6      {showActions && <button>Anuluj</button>}
7      {showFooter && <span>Priorytet: {mission.priority}/10</span>}
8    </div>
9  );
10}

Karta sama decyduje o wszystkim, więc każdy pomysł nowego ekranu oznacza zmianę jej kodu. Po miesiącu flag jest kilkanaście i nikt nie odważy się ich ruszyć. Na statku ten problem rozwiązano dawno temu: moduł ma standardowe gniazdo, a inżynier decyduje, czy podłączy do niego silnik plazmowy, czy napęd jonowy. W React ta zasada nazywa się Inversion of Control (IoC), czyli odwrócenie sterowania.

Czym jest Inversion of Control?

Inversion of Control polega na tym, że komponent oddaje konsumentowi, czyli komponentowi, który go renderuje, kontrolę nad swoim zachowaniem i wyglądem. Zamiast przewidywać każdy przypadek flagą, zostawia miejsca, które konsument wypełnia sam: propsy, sloty, render props i własne reguły stanu. Część tych narzędzi znasz już z lekcji o kompozycji. Teraz złożymy z nich elastyczne API, a na końcu ułożymy je od najmniejszego do największego oddania kontroli.

Slot Pattern - nazwane sloty

W lekcji o kompozycji karta miała tytuł i children. Teraz dostaje trzy nazwane sloty: header, actions i footer. Każdy z nich to zwykły prop, w którym przekazujesz gotowy element JSX:

1function SpaceCard({ header, footer, actions, children }) {
2  return (
3    <div className="space-card">
4      {header && <div className="card-header">{header}</div>}
5      <div className="card-body">{children}</div>
6      {actions && <div className="card-actions">{actions}</div>}
7      {footer && <div className="card-footer">{footer}</div>}
8    </div>
9  );
10}

Zapis {header && ...} renderuje ramkę slotu tylko wtedy, gdy konsument coś do niego przekazał, więc karta bez stopki nie zostawia pustego paska. children działa dokładnie tak jak wcześniej. Tak wypełnia sloty przebudowana MissionCard z mostka:

1function MissionCard({ mission }) {
2  return (
3    <SpaceCard
4      header={<h3>{mission.name}</h3>}
5      actions={
6        <>
7          <button>Szczegóły</button>
8          <button>Anuluj</button>
9        </>
10      }
11      footer={<span>Priorytet: {mission.priority}/10</span>}
12    >
13      <p>Odległość: {mission.distance} l.ś.</p>
14    </SpaceCard>
15  );
16}

Jeden slot może przyjąć kilka elementów: fragment <>...</> pakuje oba przyciski w jedną wartość. Archiwum użyje tej samej karty bez actions, a SpaceCard nie dostanie ani jednej nowej flagi.

Komponent polimorficzny - prop as

Przycisk akcji i odnośnik do raportu mają wyglądać tak samo, ale w HTML to różne elementy: akcję wykonuje <button>, a pod adres prowadzi <a>. Zamiast pisać dwa komponenty z tymi samymi stylami, pozwól konsumentowi wybrać element propem as:

1function SpaceButton({ as: Component = 'button', children, ...props }) {
2  return <Component {...props}>{children}</Component>;
3}

Zapis as: Component = 'button' odczytuje prop as, nazywa go Component i nadaje mu wartość domyślną 'button'. Wielka litera jest konieczna: JSX traktuje nazwy pisane małą literą jak znaczniki HTML, więc zapis <as> nie zadziała. Pozostałe propsy, na przykład href czy onClick, trafiają do props i przechodzą dalej bez zmian. Pełna wersja dokłada wariant i liczy klasy w zmiennej cls:

1function SpaceButton({ as: Component = 'button', variant = 'primary', children, ...props }) {
2  const cls = `space-btn btn-${variant}`;
3  return (
4    <Component className={cls} {...props}>
5      {children}
6    </Component>
7  );
8}

{...props} stoi po className, więc konsument może w razie potrzeby nadpisać nawet klasę. W React 19 także ref jest zwykłym propem, więc dotrze do elementu razem z resztą, bez forwardRef. Pozostaje użycie. Link z biblioteki React Router przyjmuje adres w propie to, a nie href:

1function MissionToolbar({ onStart }) {
2  return (
3    <nav>
4      <SpaceButton onClick={onStart}>Start</SpaceButton>
5      <SpaceButton as="a" href="/reports">Raporty</SpaceButton>
6      <SpaceButton as={Link} to="/missions" variant="danger">Lista misji</SpaceButton>
7    </nav>
8  );
9}

Pierwszy renderuje <button>, drugi <a href>, a trzeci komponent Link, który sam wyrenderuje odnośnik. Wszystkie trzy dostają klasy z jednego miejsca, a SpaceButton nie wie, czym zostanie. Używaj as, żeby wybrać właściwą semantykę, a nie żeby ją obejść: <SpaceButton as="div"> wygląda jak przycisk, ale nie dostaje fokusu i nie reaguje na Enter.

Wstrzykiwanie komponentu - renderItem

Lista misji na mostku pokazuje same nazwy, a ekran misji pełne karty. Gdyby MissionList importowała konkretną kartę, zostałaby przywiązana do jednego wyglądu. Zamiast tego konsument wstrzykuje sposób renderowania elementu: funkcję renderItem, czyli render prop znany Ci z lekcji o kompozycji:

1function MissionList({ missions, renderItem, emptyState }) {
2  if (missions.length === 0) {
3    return emptyState || <p>Brak misji</p>;
4  }
5  return (
6    <ul className="mission-list">
7      {missions.map((mission, index) => (
8        <li key={mission.id}>{renderItem(mission, index)}</li>
9      ))}
10    </ul>
11  );
12}

Lista nadal pilnuje tego, co wspólne: znacznika <ul>, klucza key i pustego stanu. O treści elementu decyduje konsument, a drugi argument, index, pozwala mu na przykład numerować misje. Gdy lista jest pusta, MissionList pokazuje slot emptyState albo domyślny tekst. Dwa ekrany, ta sama lista:

1function BridgeScreen({ missions }) {
2  return (
3    <MissionList
4      missions={missions}
5      renderItem={(mission, index) => <span>{index + 1}. {mission.name}</span>}
6    />
7  );
8}
9
10function MissionsScreen({ missions }) {
11  return (
12    <MissionList
13      missions={missions}
14      renderItem={(mission) => <MissionCard mission={mission} />}
15      emptyState={<p>Flota czeka na rozkazy.</p>}
16    />
17  );
18}

Kod MissionList nie zmienił się ani o znak, a raz pokazuje ponumerowane nazwy, a raz pełne karty ze slotami. Nazwa component injection obejmuje też przekazanie samego komponentu: as={Link} z poprzedniej sekcji to właśnie wstrzyknięty komponent.

Props collection i prop getters

W poprzedniej lekcji getToggleProps() zwracał gotowy zestaw atrybutów. Ten pomysł ma dwie odmiany. Props collection to zwykły obiekt z propsami, który konsument rozkłada na swoim elemencie. Prop getter to funkcja, która zwraca taki obiekt, ale najpierw przyjmuje propsy konsumenta i łączy je z własnymi. Uproszczony useToggle pokazuje obie odmiany obok siebie:

1function useToggle(initialState = false) {
2  const [isOpen, setIsOpen] = useState(initialState);
3  const toggle = () => setIsOpen((prev) => !prev);
4
5  // props collection: gotowy obiekt
6  const toggleProps = { onClick: toggle, 'aria-expanded': isOpen };
7
8  // prop getter: funkcja, która łączy propsy konsumenta z naszymi
9  const getToggleProps = ({ onClick, ...props } = {}) => ({
10    ...props,
11    'aria-expanded': isOpen,
12    onClick: (event) => {
13      onClick?.(event);
14      toggle();
15    },
16  });
17
18  return { isOpen, toggleProps, getToggleProps };
19}

Różnica wychodzi na jaw, gdy konsument chce dodać własny onClick, na przykład wpis do dziennika. Przy <button {...toggleProps} onClick={log}> jego funkcja zastępuje naszą i panel przestaje się otwierać. Prop getter wywołuje obie: najpierw funkcję konsumenta, o ile ją podał (stąd onClick?.(event)), a potem toggle. Pozostałe propsy, takie jak className, przechodzą bez zmian:

1function MissionReport({ onLog }) {
2  const { isOpen, getToggleProps } = useToggle();
3
4  return (
5    <section>
6      <button {...getToggleProps({ onClick: () => onLog('Raport przełączony') })}>
7        Raport misji
8      </button>
9      {isOpen && <p>Wszystkie systemy działają.</p>}
10    </section>
11  );
12}

Prop getters stosują biblioteki takie jak Downshift, a nazwy obu wzorców spopularyzował Kent C. Dodds. Dlatego polecam zwracać prop getters zamiast gotowych obiektów: kosztują kilka linijek więcej, a konsument nie zepsuje działania ani dostępności, dokładając własny onClick.

State Reducer - konsument zmienia reguły

Najdalej idzie State Reducer. Komponent ma domyślną logikę stanu, ale konsument może podmienić reguły przejść bez kopiowania kodu komponentu. Weźmy śluzę statku. Domyślnie reaguje na dwie akcje, OPEN i CLOSE, a opisuje je reducer, czyli funkcja, która ze stanu i akcji liczy nowy stan:

1function airlockReducer(isOpen, action) {
2  switch (action.type) {
3    case 'OPEN': return true;
4    case 'CLOSE': return false;
5    default: return isOpen;
6  }
7}

Hook przyjmuje reducer w opcjach, a gdy konsument go nie poda, używa domyślnego. Stanem zarządza useReducer, a dispatch wysyła do reducera akcję:

1function useAirlock({ reducer = airlockReducer } = {}) {
2  const [isOpen, dispatch] = useReducer(reducer, false);
3  const open = () => dispatch({ type: 'OPEN' });
4  const close = () => dispatch({ type: 'CLOSE' });
5  return { isOpen, open, close };
6}

Wartość domyślna = {} pozwala wywołać useAirlock() bez argumentów. Teraz konsument dokłada własną regułę: dopóki statek nie zadokuje, śluza się nie otworzy:

1function DockingBay({ docked }) {
2  const airlock = useAirlock({
3    reducer: (isOpen, action) => {
4      if (action.type === 'OPEN' && !docked) {
5        return isOpen; // w próżni śluza zostaje zamknięta
6      }
7      return airlockReducer(isOpen, action);
8    },
9  });
10
11  return (
12    <button onClick={airlock.isOpen ? airlock.close : airlock.open}>
13      {airlock.isOpen ? 'Zamknij śluzę' : 'Otwórz śluzę'}
14    </button>
15  );
16}

Konsument przechwytuje tylko jedną akcję, a resztę oddaje domyślnemu airlockReducer, więc useAirlock nie zmienił się wcale. Reducer musi pozostać czystą funkcją: bez window.confirm, zapytań do API i modyfikowania obiektów, bo w trybie deweloperskim, pod <StrictMode>, React wywołuje go dwa razy.

Który wzorzec wybrać?

Każda z tych technik odpowiada na inną potrzebę konsumenta:

SytuacjaPodejście
Ta sama struktura, różna zawartość częściSlot Pattern
Różne elementy HTML lub komponenty routeraKomponent polimorficzny (as)
Różny wygląd elementów listyWstrzykiwanie komponentu (renderItem)
Gotowe atrybuty i handlery do rozłożenia na elemencieProps collection, a lepiej prop getter
Inne reguły zmiany stanuState Reducer

Poziomy kontroli

Te same techniki można ułożyć według tego, ile kontroli oddajesz konsumentowi:

  1. Props: najmniej IoC, komponent dostaje dane i sam decyduje, jak je wyświetlić.
  2. Sloty i children: konsument decyduje o zawartości wybranych miejsc.
  3. Render props i component injection: konsument decyduje o całym renderowaniu elementu.
  4. State reducer: najwięcej IoC, konsument zmienia nawet logikę stanu.

Im wyżej na tej liście, tym komponent elastyczniejszy, ale też trudniejszy w użyciu i w opisaniu. Jak na mostku: za mało przełączników nie pozwala pilotowi zareagować na niespodziankę, a za dużo spowalnia go przy każdym manewrze. Moja rada: zaczynaj od najniższego poziomu, który rozwiązuje problem, i wspinaj się wyżej dopiero wtedy, gdy konsument naprawdę potrzebuje więcej. W następnej lekcji oddasz kontrolę jeszcze dalej: zespół będzie włączał i wyłączał całe funkcje flagami, bez wdrażania nowego kodu.

Pamiętaj: komponent z odwróconym sterowaniem to gniazdo na statku, które daje złącze, a o tym, co do niego podłączyć, decyduje konsument.

Kod do tej lekcji: App.jsx
1import React, { useState, useReducer } from 'react';
2
3// === SLOT PATTERN (nazwane sloty): SpaceCard ===
4function SpaceCard({ header, footer, actions, children }) {
5  return (
6    <div className="space-card">
7      {header && <div className="card-header">{header}</div>}
8      <div className="card-body">{children}</div>
9      {actions && <div className="card-actions">{actions}</div>}
10      {footer && <div className="card-footer">{footer}</div>}
11    </div>
12  );
13}
14
15// === KOMPONENT POLIMORFICZNY: SpaceButton z propem "as" ===
16function SpaceButton({ as: Component = 'button', variant = 'primary', children, ...props }) {
17  const cls = `space-btn btn-${variant}`;
18  return (
19    <Component className={cls} {...props}>
20      {children}
21    </Component>
22  );
23}
24
25// === WSTRZYKIWANIE KOMPONENTU: MissionList z renderItem i emptyState ===
26function MissionList({ missions, renderItem, emptyState }) {
27  if (missions.length === 0) {
28    return emptyState || <p className="empty">Brak misji</p>;
29  }
30  return (
31    <ul className="mission-list">
32      {missions.map((mission, index) => (
33        <li key={mission.id}>{renderItem(mission, index)}</li>
34      ))}
35    </ul>
36  );
37}
38
39// Karta misji wypełnia sloty SpaceCard
40function MissionCard({ mission, onCancel }) {
41  const [showStatus, setShowStatus] = useState(false);
42  return (
43    <SpaceCard
44      header={<h4>{mission.name}</h4>}
45      actions={
46        <>
47          <SpaceButton aria-expanded={showStatus} onClick={() => setShowStatus((s) => !s)}>Szczegóły</SpaceButton>
48          <SpaceButton variant="danger" onClick={onCancel}>Anuluj</SpaceButton>
49        </>
50      }
51      footer={<span className="meta">Priorytet: {mission.priority}/10</span>}
52    >
53      <p>Odległość: {mission.distance} l.ś.</p>
54      {showStatus && <p>Status: {STATUS_LABELS[mission.status]}</p>}
55    </SpaceCard>
56  );
57}
58
59// === PROPS COLLECTION I PROP GETTER: useToggle ===
60function useToggle(initialState = false) {
61  const [isOpen, setIsOpen] = useState(initialState);
62  const toggle = () => setIsOpen((prev) => !prev);
63
64  // props collection: gotowy obiekt
65  const toggleProps = { onClick: toggle, 'aria-expanded': isOpen };
66
67  // prop getter: łączy propsy konsumenta z naszymi
68  const getToggleProps = ({ onClick, ...props } = {}) => ({
69    ...props,
70    'aria-expanded': isOpen,
71    onClick: (event) => {
72      onClick?.(event);
73      toggle();
74    },
75  });
76
77  return { isOpen, toggleProps, getToggleProps };
78}
79
80// === STATE REDUCER: śluza z domyślnymi regułami ===
81function airlockReducer(isOpen, action) {
82  switch (action.type) {
83    case 'OPEN': return true;
84    case 'CLOSE': return false;
85    default: return isOpen;
86  }
87}
88
89function useAirlock({ reducer = airlockReducer } = {}) {
90  const [isOpen, dispatch] = useReducer(reducer, false);
91  const open = () => dispatch({ type: 'OPEN' });
92  const close = () => dispatch({ type: 'CLOSE' });
93  return { isOpen, open, close };
94}
95
96// === ETYKIETY I DANE ===
97// Statusy zostają w danych po angielsku (z nich powstają klasy CSS), a na ekranie widać polskie etykiety
98const STATUS_LABELS = { active: 'W toku', completed: 'Ukończona' };
99
100const INITIAL_MISSIONS = [
101  { id: 1, name: 'Wyprawa na Alfa Centauri', distance: '4,37', status: 'active', priority: 9 },
102  { id: 2, name: 'Patrol przy Syriuszu', distance: '8,6', status: 'completed', priority: 5 },
103  { id: 3, name: 'Badanie układu Tau Ceti', distance: '11,9', status: 'active', priority: 8 },
104];
105
106// === GŁÓWNY KOMPONENT ===
107export default function App() {
108  const [updatedAt, setUpdatedAt] = useState('teraz');
109  const [lastTag, setLastTag] = useState(null);
110  const [view, setView] = useState('compact');
111  const [missions, setMissions] = useState(INITIAL_MISSIONS);
112  const [notes, setNotes] = useState(0);
113  const [docked, setDocked] = useState(false);
114  const report = useToggle();
115  const airlock = useAirlock({
116    // reguła konsumenta: bez dokowania akcja OPEN niczego nie zmienia
117    reducer: (isOpen, action) => {
118      if (action.type === 'OPEN' && !docked) return isOpen;
119      return airlockReducer(isOpen, action);
120    },
121  });
122
123  const showTag = (e) => {
124    e.preventDefault();
125    setLastTag(e.currentTarget.tagName.toLowerCase());
126  };
127  const addNote = () => setNotes((n) => n + 1);
128  const cancelMission = (id) => setMissions((prev) => prev.filter((m) => m.id !== id));
129
130  return (
131    <div className="app">
132      <h1>Demo: Inversion of Control</h1>
133      <p className="subtitle">Sloty, prop as, renderItem, prop getter i state reducer</p>
134
135      <section className="section">
136        <h3>Sloty (SpaceCard)</h3>
137        <SpaceCard
138          header={<h4>Stan statku</h4>}
139          actions={<SpaceButton onClick={() => setUpdatedAt(new Date().toLocaleTimeString('pl-PL'))}>Odśwież</SpaceButton>}
140          footer={<span className="meta">Ostatnia aktualizacja: {updatedAt}</span>}
141        >
142          <p>Paliwo: 87% | Prędkość: 14,2 tys. km/s</p>
143        </SpaceCard>
144        <SpaceCard header={<h4>Archiwum</h4>}>
145          <p>Bez slotów actions i footer karta nie rysuje pustych pasków.</p>
146        </SpaceCard>
147      </section>
148
149      <section className="section">
150        <h3>Komponent polimorficzny (prop as)</h3>
151        <div className="btn-row">
152          <SpaceButton onClick={showTag}>Przycisk</SpaceButton>
153          <SpaceButton as="a" href="#raporty" onClick={showTag}>Odnośnik</SpaceButton>
154          <SpaceButton as="a" href="#archiwum" variant="danger" onClick={showTag}>Odnośnik (danger)</SpaceButton>
155        </div>
156        <p className="meta">Kliknięty element: {lastTag ? '<' + lastTag + '>' : 'jeszcze żaden'}</p>
157      </section>
158
159      <section className="section">
160        <h3>Wstrzykiwanie komponentu (renderItem)</h3>
161        <p className="hint">W widoku szczegółowym Anuluj usuwa misję. Usuń wszystkie, a zobaczysz slot emptyState.</p>
162        <div className="toggle-row">
163          <button className={'chip ' + (view === 'compact' ? 'active' : '')} aria-pressed={view === 'compact'} onClick={() => setView('compact')}>Kompaktowy</button>
164          <button className={'chip ' + (view === 'detailed' ? 'active' : '')} aria-pressed={view === 'detailed'} onClick={() => setView('detailed')}>Szczegółowy</button>
165        </div>
166        <MissionList
167          missions={missions}
168          renderItem={view === 'compact'
169            ? (mission, index) => (
170                <div className="compact-item">
171                  <span>{index + 1}. {mission.name}</span>
172                  <span className={'badge badge-' + mission.status}>{STATUS_LABELS[mission.status]}</span>
173                </div>
174              )
175            : (mission) => <MissionCard mission={mission} onCancel={() => cancelMission(mission.id)} />
176          }
177          emptyState={
178            <div className="empty">
179              <p>Flota czeka na rozkazy.</p>
180              <SpaceButton onClick={() => setMissions(INITIAL_MISSIONS)}>Przywróć misje</SpaceButton>
181            </div>
182          }
183        />
184      </section>
185
186      <section className="section">
187        <h3>Props collection a prop getter (useToggle)</h3>
188        <p className="hint">Oba przyciski dostają onClick konsumenta, który dodaje wpis. Przy kolekcji zastępuje on onClick hooka, więc raport się nie przełącza. Getter wywołuje oba.</p>
189        <div className="btn-row">
190          <button className="chip" {...report.toggleProps} onClick={addNote}>Kolekcja + własny onClick</button>
191          <button className="chip" {...report.getToggleProps({ onClick: addNote })}>Getter + własny onClick</button>
192        </div>
193        <p className="meta">Wpisy konsumenta: {notes} | Raport: {report.isOpen ? 'otwarty' : 'zamknięty'}</p>
194        {report.isOpen && <p className="report">Raport misji: wszystkie systemy działają.</p>}
195      </section>
196
197      <section className="section">
198        <h3>State Reducer (useAirlock)</h3>
199        <label className="check">
200          <input type="checkbox" checked={docked} onChange={(e) => setDocked(e.target.checked)} />
201          Statek zadokowany
202        </label>
203        <div className="btn-row">
204          <SpaceButton onClick={airlock.open}>Otwórz śluzę</SpaceButton>
205          <SpaceButton variant="danger" onClick={airlock.close}>Zamknij śluzę</SpaceButton>
206        </div>
207        <p className="meta">Śluza: {airlock.isOpen ? 'otwarta' : 'zamknięta'}{docked ? '' : ' (bez dokowania reguła konsumenta ignoruje OPEN)'}</p>
208      </section>
209    </div>
210  );
211}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Na czym polega Inversion of Control (IoC) w kontekście komponentów React?

  2. 2. Co umożliwia prop 'as' w polymorphic components, np. <SpaceButton as="a" href="/link">?

Zadania praktyczne w grze

  • Edytor kodu

    Dokończ trzy komponenty, które oddają kontrolę konsumentowi. SpaceCard (Slot Pattern): ___BLANK1___ i ___BLANK2___ to warunki, dzięki którym <header> i <footer> pojawiają się tylko wtedy, gdy konsument podał prop header albo footer (tak jak już działa actions). SpaceButton (polymorphic): ___BLANK3___ to nazwa, pod którą zmieniamy prop as, żeby użyć jej w JSX jako elementu, a ___BLANK4___ przekazuje elementowi resztę propsów (onClick, href, to). MissionList (Component Injection): ___BLANK5___ to element pokazywany zamiast pustej listy (gdy go nie podano, zostaje domyślny komunikat), a ___BLANK6___ wywołuje funkcję renderItem z misją i jej indeksem.

  • Układanie w pionie

    Ułóż poziomy Inversion of Control w React od NAJMNIEJSZEGO do NAJWIĘKSZEGO oddania kontroli:

  • Układanie w poziomie

    Ułóż składnię definicji polymorphic component z prop 'as':

Przydatne artykuły