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

Headless Components - Oddzielenie logiki od prezentacji

7 min czytania
W tej lekcji7

Kokpit i panel inżynierski potrzebują tej samej listy rozwijanej: otwieranie, zamykanie, wybór opcji, obsługa klawiatury i atrybuty dostępności. Kokpit chce jednak prostego przycisku, a inżynierowie kosmicznego menu z ikonami. Dwa komponenty to dwie kopie trudnej logiki i dwa miejsca na błędy. Lepszy jest uniwersalny moduł sterowania, który podłączysz do dowolnego panelu. To właśnie idea Headless Components: komponentów, które zawierają całą logikę, ale zero UI.

Czym są Headless Components?

Headless Component to komponent lub hook, który:

  1. Zarządza stanem i logiką (otwieranie/zamykanie, selekcja, nawigacja)
  2. NIE renderuje żadnego JSX, czyli nie ma własnego wyglądu
  3. Oddaje kontrolę nad UI konsumentowi przez propsy, render props lub hooki

Najczęściej to po prostu custom hook, jak useCrew z poprzedniej lekcji, tylko zamiast danych z API zarządza zachowaniem interfejsu. Zbudujmy headless dropdown krok po kroku. Najpierw stan listy i trzy proste akcje:

1// Headless Dropdown: cała logika, zero UI
2function useDropdown(options) {
3  const [isOpen, setIsOpen] = useState(false);
4  const [selectedIndex, setSelectedIndex] = useState(-1);
5  const [selectedOption, setSelectedOption] = useState(null);
6
7  const toggle = () => setIsOpen(prev => !prev);
8  const close = () => setIsOpen(false);
9
10  const select = (index) => {
11    setSelectedIndex(index);
12    setSelectedOption(options[index]);
13    setIsOpen(false);
14  };

Nic z tego nie pojawi się jeszcze na ekranie: to czysta logika, która zadziała w każdym wyglądzie. Teraz obsługa klawiatury, czyli część najczęściej pomijana przy pisaniu dropdownu od zera:

1  // Nawigacja klawiaturą
2  const handleKeyDown = (e) => {
3    if (e.key === 'ArrowDown') {
4      e.preventDefault();
5      setSelectedIndex(prev => Math.min(prev + 1, options.length - 1));
6    } else if (e.key === 'ArrowUp') {
7      e.preventDefault();
8      setSelectedIndex(prev => Math.max(prev - 1, 0));
9    } else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) {
10      e.preventDefault();
11      select(selectedIndex);
12    } else if (e.key === 'Escape') {
13      close();
14    }
15  };

Strzałki przesuwają indeks w granicach listy, Enter zatwierdza wybór, a Escape zamyka listę. e.preventDefault() wyłącza domyślne reakcje przeglądarki: strzałki nie przewijają strony, a Enter nie dokłada zwykłego kliknięcia przycisku, które od razu otworzyłoby listę z powrotem. Zamkniętą listę otwiera właśnie to kliknięcie, bo na przycisku Enter i spacja działają jak klik myszą. W tej uproszczonej wersji jeden indeks służy i do podświetlenia, i do zaznaczenia, a biblioteki rozdzielają te dwie rzeczy. Na koniec hook zwraca wszystko, czego potrzebuje widok, w tym dwie funkcje zwracające gotowe zestawy propsów:

1  return {
2    isOpen, selectedIndex, selectedOption,
3    toggle, close, select, handleKeyDown,
4    getToggleProps: () => ({
5      onClick: toggle,
6      onKeyDown: handleKeyDown,
7      'aria-expanded': isOpen,
8      'aria-haspopup': 'listbox',
9    }),
10    getOptionProps: (index) => ({
11      onClick: () => select(index),
12      'aria-selected': selectedIndex === index,
13      role: 'option',
14    }),
15  };
16}

getToggleProps() oddaje onClick, onKeyDown, aria-expanded i aria-haspopup, a getOptionProps(index) dodaje każdej opcji role i aria-selected. Konsument rozkłada te obiekty operatorem spread i nie musi pamiętać o dostępności. Takie funkcje nazywa się prop getters, a spotkasz je choćby w bibliotece Downshift.

Użycie headless dropdown z dowolnym UI

Klucz do headless components: ten sam hook, różne wyglądy. Pierwsza wersja to zwykły przycisk i lista, bez żadnych ozdobników:

1// Wersja 1: Prosty dropdown
2function SimpleDropdown({ options, label }) {
3  const dropdown = useDropdown(options);
4
5  return (
6    <div className="simple-dropdown">
7      <button {...dropdown.getToggleProps()}>
8        {dropdown.selectedOption || label}
9      </button>
10      {dropdown.isOpen && (
11        <ul role="listbox">
12          {options.map((opt, i) => (
13            <li key={i} {...dropdown.getOptionProps(i)}>
14              {opt}
15            </li>
16          ))}
17        </ul>
18      )}
19    </div>
20  );
21}

Druga wersja korzysta z identycznego hooka, ale buduje kosmiczne menu z ikonami i strzałką, która pokazuje stan listy:

1// Wersja 2: Kosmiczny dropdown z ikonami
2function SpaceDropdown({ options, icons }) {
3  const dropdown = useDropdown(options);
4
5  return (
6    <div className="space-dropdown">
7      <button type="button" {...dropdown.getToggleProps()} className="space-trigger">
8        <span>{dropdown.selectedOption || 'Wybierz system'}</span>
9        <span>{dropdown.isOpen ? '▲' : '▼'}</span>
10      </button>
11      {dropdown.isOpen && (
12        <div className="space-menu">
13          {options.map((opt, i) => (
14            <div key={i} {...dropdown.getOptionProps(i)} className="space-option">
15              <span>{icons[i]}</span>
16              <span>{opt}</span>
17            </div>
18          ))}
19        </div>
20      )}
21    </div>
22  );
23}

Oba komponenty różnią się tylko JSX-em, a hook nie zmienił się ani o linijkę. Wynik możesz też od razu rozpakować: const { isOpen, selectedOption, getToggleProps, getOptionProps } = useDropdown(options). Zwróć uwagę, że wyzwalaczem znowu jest <button>, choć wygląda zupełnie inaczej niż w pierwszej wersji. Hook dostarcza atrybuty ARIA, ale element wybierasz Ty: <div> nie dostałby fokusu z klawiatury, a nawet z tabIndex={0} i role="button" Enter i spację musiałbyś obsłużyć ręcznie.

Headless Toggle / Disclosure

Kolejny przykład to headless toggle do budowania akordeonów, rozwijanych sekcji i spoilerów. Ten hook jest krótki, bo pilnuje tylko jednej wartości logicznej:

1function useToggle(initialState = false) {
2  const [isOpen, setIsOpen] = useState(initialState);
3
4  return {
5    isOpen,
6    toggle: () => setIsOpen(p => !p),
7    open: () => setIsOpen(true),
8    close: () => setIsOpen(false),
9    getToggleProps: () => ({
10      onClick: () => setIsOpen(p => !p),
11      'aria-expanded': isOpen,
12    }),
13    getPanelProps: () => ({
14      role: 'region',
15      hidden: !isOpen,
16    }),
17  };
18}

Na tym samym przełączniku zbudujemy dwa zupełnie różne komponenty. Pierwszy to akordeon, który rozwija opis systemu statku:

1// Użycie 1: akordeon
2function SystemDetails({ title, children }) {
3  const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5  return (
6    <div className="accordion-item">
7      <button {...getToggleProps()}>
8        {title} {isOpen ? '−' : '+'}
9      </button>
10      <div {...getPanelProps()}>
11        {isOpen && children}
12      </div>
13    </div>
14  );
15}

Tooltip wygląda inaczej, ale korzysta z identycznego API hooka, więc logika otwierania i zamykania nie jest powielana:

1// Użycie 2: Tooltip
2function InfoTooltip({ content, children }) {
3  const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5  return (
6    <span className="tooltip-wrapper">
7      <button type="button" {...getToggleProps()} className="tooltip-trigger">
8        {children}
9      </button>
10      <span {...getPanelProps()} className="tooltip-content">
11        {isOpen && content}
12      </span>
13    </span>
14  );
15}

getPanelProps() ustawia hidden i role="region": dzięki hidden zwinięty panel znika także dla czytników ekranu, a nie tylko wizualnie. Wyzwalaczem znowu jest <button>, bo aria-expanded wolno nadać tylko elementom z rolą, takim jak przycisk, a <span> nie dostałby fokusu. Ten sam kształt API znajdziesz w bibliotekach headless pod nazwą Disclosure.

Headless Tabs

Zakładki to trzeci klasyk. Hook pamięta aktywną zakładkę i nadaje role tab oraz tabpanel, które czytniki ekranu rozumieją bez dodatkowych opisów:

1function useTabs(tabCount, defaultTab = 0) {
2  const [activeTab, setActiveTab] = useState(defaultTab);
3
4  return {
5    activeTab,
6    setActiveTab,
7    getTabProps: (index) => ({
8      onClick: () => setActiveTab(index),
9      role: 'tab',
10      'aria-selected': activeTab === index,
11      tabIndex: activeTab === index ? 0 : -1,
12    }),
13    getPanelProps: (index) => ({
14      role: 'tabpanel',
15      hidden: activeTab !== index,
16    }),
17    isActive: (index) => activeTab === index,
18  };
19}

tabIndex równy 0 tylko dla aktywnej zakładki to technika roving tabindex: klawisz Tab trafia od razu na aktywną zakładkę. Wzorzec zakładek z WAI-ARIA dodaje jeszcze przełączanie strzałkami, do którego przyda się nieużywany na razie parametr tabCount.

Dlaczego headless?

CechaZwykły komponentHeadless Component
UIWbudowane style/JSXZero, TY decydujesz
ReużywalnośćOgraniczona do jednego wygląduNieograniczona
DostępnośćTrzeba implementować ręcznieWbudowane aria-atrybuty
TestowalnośćWymaga renderowaniaTestuj sam hook

Headless UI w ekosystemie React

Biblioteka @headlessui/react (od twórców Tailwind CSS) dostarcza gotowe headless komponenty:

  • Listbox: dropdown z pełną obsługą klawiatury
  • Combobox: autocomplete/search
  • Dialog: modal z focus trap
  • Disclosure: accordion/expandable
  • Menu: dropdown menu
  • Tabs: zakładki

Każdy z nich dostarcza logikę, aria-atrybuty i obsługę klawiatury, ale zero stylowania. Ty dostarczasz cały wygląd. Podobnie działają Radix UI Primitives oraz React Aria od Adobe.

Kiedy używać headless components?

  • Budujesz design system: różne zespoły potrzebują tych samych interakcji, ale różnych stylów
  • Potrzebujesz pełnej dostępności (a11y): headless komponenty mają wbudowane aria-atrybuty
  • Tworzysz komponenty używane w wielu projektach: logika ta sama, style różne
  • Chcesz oddzielić odpowiedzialność: hook = logika, komponent = wygląd

Headless Components działają jak uniwersalny system nawigacyjny statku: logika jest ta sama, ale każdy panel wygląda inaczej, bo każda stacja ma inne potrzeby. Moja rada: własny headless hook pisz dla prostych przełączników, takich jak useToggle. Dropdown, combobox czy dialog z pełną obsługą klawiatury i fokusu to dużo pracy, więc tam sięgnij po sprawdzoną bibliotekę. W następnej lekcji oddamy użytkownikowi komponentu jeszcze więcej kontroli dzięki Inversion of Control i zobaczymy, czym prop getters różnią się od props collection.

Pamiętaj: headless hook to silnik bez kadłuba, a kadłub projektujesz sam.

Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3// === HEADLESS HOOK: useDropdown ===
4function useDropdown(options) {
5  const [isOpen, setIsOpen] = useState(false);
6  const [selectedIndex, setSelectedIndex] = useState(-1);
7  const [selectedOption, setSelectedOption] = useState(null);
8
9  const toggle = () => setIsOpen(p => !p);
10  const close = () => setIsOpen(false);
11
12  const select = (index) => {
13    setSelectedIndex(index);
14    setSelectedOption(options[index]);
15    setIsOpen(false);
16  };
17
18  // preventDefault: strzałki nie przewijają strony, a Enter nie dokłada kliknięcia, które znowu otworzyłoby listę
19  const handleKeyDown = (e) => {
20    if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, options.length - 1)); }
21    else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)); }
22    else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) { e.preventDefault(); select(selectedIndex); }
23    else if (e.key === 'Escape') close();
24  };
25
26  return {
27    isOpen, selectedIndex, selectedOption, toggle, close, select, handleKeyDown,
28    getToggleProps: () => ({ onClick: toggle, onKeyDown: handleKeyDown, 'aria-expanded': isOpen, 'aria-haspopup': 'listbox' }),
29    getOptionProps: (i) => ({ onClick: () => select(i), 'aria-selected': selectedIndex === i, role: 'option' }),
30  };
31}
32
33// === HEADLESS HOOK: useToggle ===
34function useToggle(initial = false) {
35  const [isOpen, setIsOpen] = useState(initial);
36  return {
37    isOpen,
38    toggle: () => setIsOpen(p => !p),
39    getToggleProps: () => ({ onClick: () => setIsOpen(p => !p), 'aria-expanded': isOpen }),
40    getPanelProps: () => ({ role: 'region', hidden: !isOpen }),
41  };
42}
43
44// === KOMPONENT WIZUALNY 1: prosty dropdown ===
45function SimpleDropdown({ options, label }) {
46  const dd = useDropdown(options);
47  return (
48    <div className="dropdown">
49      <button className="dd-trigger" {...dd.getToggleProps()}>
50        {dd.selectedOption || label} <span>{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
51      </button>
52      {dd.isOpen && (
53        <ul className="dd-menu" role="listbox">
54          {options.map((opt, i) => (
55            <li key={i} className={"dd-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
56              {opt}
57            </li>
58          ))}
59        </ul>
60      )}
61    </div>
62  );
63}
64
65// === KOMPONENT WIZUALNY 2: kosmiczny dropdown (inny wygląd, ta sama logika) ===
66function SpaceDropdown({ options, icons, label }) {
67  const dd = useDropdown(options);
68  return (
69    <div className="space-dd">
70      <button type="button" className="space-trigger" {...dd.getToggleProps()}>
71        <span>{dd.selectedOption || label}</span>
72        <span className="arrow">{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
73      </button>
74      {dd.isOpen && (
75        <div className="space-menu">
76          {options.map((opt, i) => (
77            <div key={i} className={"space-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
78              <span className="opt-icon">{icons[i]}</span>
79              <span>{opt}</span>
80            </div>
81          ))}
82        </div>
83      )}
84    </div>
85  );
86}
87
88// === Akordeon na bazie useToggle ===
89function AccordionItem({ title, children }) {
90  const { isOpen, getToggleProps, getPanelProps } = useToggle();
91  return (
92    <div className="accordion-item">
93      <button className="accordion-header" {...getToggleProps()}>
94        {title} <span>{isOpen ? '\u2212' : '+'}</span>
95      </button>
96      <div className="accordion-body" {...getPanelProps()}>
97        {isOpen && <div className="accordion-content">{children}</div>}
98      </div>
99    </div>
100  );
101}
102
103// === GŁÓWNY KOMPONENT ===
104export default function App() {
105  return (
106    <div className="app">
107      <h1>Demo komponentów headless</h1>
108      <p className="subtitle">Te same hooki z logiką, różne wersje wyglądu</p>
109
110      <div className="section">
111        <h3>Prosty dropdown (useDropdown)</h3>
112        <SimpleDropdown options={['Mars', 'Jowisz', 'Saturn', 'Europa']} label="Wybierz planetę..." />
113      </div>
114
115      <div className="section">
116        <h3>Kosmiczny dropdown (ten sam useDropdown, inny wygląd)</h3>
117        <SpaceDropdown
118          options={['Silniki', 'Osłony', 'Nawigacja', 'Uzbrojenie']}
119          icons={['SIL', 'OSŁ', 'NAW', 'UZB']}
120          label="Wybierz system..."
121        />
122      </div>
123
124      <div className="section">
125        <h3>Akordeon (useToggle)</h3>
126        <AccordionItem title="Stan silnika">
127          <p>Napęd plazmowy pracuje z wydajnością 94%.</p>
128        </AccordionItem>
129        <AccordionItem title="Raport osłon">
130          <p>Osłony przednie: 78%. Osłony tylne: 100%.</p>
131        </AccordionItem>
132        <AccordionItem title="Nawigacja">
133          <p>Kurs na Alfa Centauri. Czas dotarcia: 4,2 roku.</p>
134        </AccordionItem>
135      </div>
136    </div>
137  );
138}

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. Czym są Headless Components w kontekście React?

  2. 2. Jaki jest cel metod getToggleProps() i getOptionProps() w headless hooks?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Dokończ dwa headless hooki i dwa wyglądy na nich zbudowane. W useDropdown ___BLANK1___: po wybraniu opcji lista się zamyka (użyj gotowej akcji close). ___BLANK2___: getToggleProps ustawia aria-expanded zgodnie z tym, czy lista jest otwarta. ___BLANK3___: getOptionProps(index) ustawia aria-selected na true tylko dla wybranej opcji (selectedIndex), dla pozostałych false. W useToggle ___BLANK4___: getPanelProps ukrywa panel atrybutem hidden, gdy przełącznik jest zamknięty. ___BLANK5___: SimpleDropdown rozkłada na przycisku propsy zwrócone przez getToggleProps hooka useDropdown (wynik wywołania, nie samą funkcję).

  • Układanie w poziomie

    Ułóż składnię destrukturyzacji wyniku headless hooka useDropdown:

  • Klikanie w kolejności

    Kliknij w kolejności kroki tworzenia komponentu headless:

Przydatne artykuły