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

Zaawansowane wzorce - State Machines i Provider Pattern

8 min czytania
W tej lekcji8

Przełącznik motywu siedzi w nagłówku, a kolory musi znać każdy panel stacji. Procedura startu ma siedem etapów i kilka flag logicznych, które potrafią się nawzajem wykluczać. Teraz, gdy znasz już podstawowe wzorce React, czas na zaawansowane techniki, które rozwiązują oba problemy: Provider Pattern do współdzielenia stanu i maszyny stanów do panowania nad złożonymi procesami. W kosmicznej stacji to jak instalowanie zaawansowanych systemów sterowania.

Provider Pattern - Centralny system dystrybucji

Provider Pattern to rozwinięcie Context API. Tworzymy dedykowany provider, który hermetyzuje zarówno stan, jak i logikę, a komponentom udostępnia tylko gotowy obiekt:

1const ThemeContext = createContext();
2
3function ThemeProvider({ children }) {
4  const [theme, setTheme] = useState('dark');
5
6  const toggleTheme = useCallback(() => {
7    setTheme(prev => prev === 'dark' ? 'light' : 'dark');
8  }, []);
9
10  const value = useMemo(() => ({
11    theme, toggleTheme
12  }), [theme, toggleTheme]);
13
14  return (
15    <ThemeContext.Provider value={value}>
16      <div className={`app-${theme}`}>
17        {children}
18      </div>
19    </ThemeContext.Provider>
20  );
21}

useCallback zachowuje tę samą funkcję toggleTheme między renderami, a useMemo tworzy obiekt value na nowo tylko przy zmianie motywu, więc konsumenci nie renderują się bez potrzeby. Drugą połową wzorca jest hook, przez który komponenty czytają kontekst:

1// Custom hook dla konsumpcji
2function useTheme() {
3  const context = useContext(ThemeContext);
4  if (!context) {
5    throw new Error('useTheme musi być użyty wewnątrz ThemeProvider');
6  }
7  return context;
8}

createContext() bez wartości domyślnej daje poza providerem undefined, więc hook rzuca czytelny błąd, zamiast pozwolić komponentowi działać na pustych danych. Przepis jest zawsze ten sam: kontekst, provider ze stanem i logiką, hook do konsumpcji, owinięcie aplikacji w provider i użycie hooka w komponentach. W React 19 kontekst przeczytasz także funkcją use, a providerem może być sam <ThemeContext>.

Według tego samego przepisu zbudujesz centrum powiadomień stacji. Provider trzyma tablicę notifications i trzy funkcje, które nigdy nie zmieniają starej tablicy, tylko tworzą nową:

1const NotificationContext = createContext(null);
2let nextId = 0;
3
4function NotificationProvider({ children }) {
5  const [notifications, setNotifications] = useState([]);
6
7  const addNotification = useCallback((message, type = 'info') => {
8    const id = ++nextId;
9    setNotifications(prev => [...prev, { id, message, type }]);
10  }, []);
11
12  const removeNotification = useCallback((id) => {
13    setNotifications(prev => prev.filter(n => n.id !== id));
14  }, []);
15
16  const clearAll = useCallback(() => setNotifications([]), []);
17
18  const value = useMemo(
19    () => ({ notifications, addNotification, removeNotification, clearAll }),
20    [notifications, addNotification, removeNotification, clearAll]
21  );
22
23  return (
24    <NotificationContext.Provider value={value}>
25      {children}
26    </NotificationContext.Provider>
27  );
28}

addNotification dokleja nowy wpis z niepowtarzalnym id z licznika, removeNotification zostawia przez filter wszystkie wpisy poza jednym, a clearAll podstawia pustą tablicę. Funkcje owinięte w useCallback się nie zmieniają, więc value powstaje na nowo tylko wtedy, gdy zmieni się lista. Hook useNotifications piszesz tak samo jak useTheme, z czytelnym błędem poza providerem.

Kompozycja providerów

Gdy mamy wiele providerów, drzewo zaczyna przypominać piramidę, a kompozycja staje się kluczowa. Zwykłe zagnieżdżenie wygląda tak:

1// Zamiast zagnieżdżania:
2function App() {
3  return (
4    <ThemeProvider>
5      <AuthProvider>
6        <NotificationProvider>
7          <AppContent />
8        </NotificationProvider>
9      </AuthProvider>
10    </ThemeProvider>
11  );
12}

Komponent ComposeProviders składa tę samą piramidę z tablicy. reduceRight zaczyna od ostatniego providera, więc pierwszy z tablicy ląduje na zewnątrz, jak w wersji zagnieżdżonej:

1// Możemy użyć compose pattern:
2function ComposeProviders({ providers, children }) {
3  return providers.reduceRight(
4    (acc, Provider) => <Provider>{acc}</Provider>,
5    children
6  );
7}
8
9function App() {
10  return (
11    <ComposeProviders
12      providers={[ThemeProvider, AuthProvider, NotificationProvider]}
13    >
14      <AppContent />
15    </ComposeProviders>
16  );
17}

Drzewo wynikowe jest identyczne, zmienił się tylko zapis. Ta sztuczka nie przekaże jednak propsów konkretnym providerom, dlatego przy trzech providerach zostałbym przy zagnieżdżeniu, a ComposeProviders zostawił na długie listy.

State Machines - Kontrolowane przejścia stanów

State machine eliminuje "niemożliwe stany" i gwarantuje, że aplikacja jest zawsze w jednym ze zdefiniowanych stanów. Zaczynamy od mapy: dla każdego stanu wypisujemy zdarzenia i stan, do którego prowadzą:

1// Stany i przejścia dla procesu startu
2const launchMachine = {
3  idle: {
4    START_PREFLIGHT: 'preflight'
5  },
6  preflight: {
7    CHECKS_PASSED: 'countdown',
8    CHECKS_FAILED: 'error'
9  },
10  countdown: {
11    COUNTDOWN_DONE: 'launching',
12    ABORT: 'aborted'
13  },
14  launching: {
15    LAUNCH_SUCCESS: 'inFlight',
16    LAUNCH_FAILURE: 'error'
17  },
18  inFlight: {},
19  error: {
20    RETRY: 'idle'
21  },
22  aborted: {
23    RETRY: 'idle'
24  }
25};

Zapis idle: { START_PREFLIGHT: 'preflight' } czytamy tak: w stanie idle zdarzenie START_PREFLIGHT przenosi nas do preflight. Stan inFlight nie ma przejść, więc jest końcowy. Teraz hook, który pilnuje tej mapy:

1function useMachine(machine, initialState) {
2  const [state, setState] = useState(initialState);
3
4  const send = useCallback((event) => {
5    setState(current => {
6      const nextState = machine[current]?.[event];
7      if (!nextState) {
8        console.warn(`Brak przejścia ${event} w stanie ${current}`);
9        return current;
10      }
11      return nextState;
12    });
13  }, [machine]);
14
15  return [state, send];
16}

send szuka przejścia w mapie, a przy nieznanym zdarzeniu zostaje w obecnym stanie i ostrzega w konsoli. Komponent tylko wysyła zdarzenia i pokazuje przyciski pasujące do stanu:

1// Użycie
2function LaunchControl() {
3  const [state, send] = useMachine(launchMachine, 'idle');
4
5  return (
6    <div className="launch-control">
7      <h2>Stan: {state}</h2>
8      {state === 'idle' && (
9        <button onClick={() => send('START_PREFLIGHT')}>
10          Rozpocznij procedury
11        </button>
12      )}
13      {state === 'preflight' && (
14        <>
15          <button onClick={() => send('CHECKS_PASSED')}>
16            Testy zaliczone
17          </button>
18          <button onClick={() => send('CHECKS_FAILED')}>
19            Testy niezdane
20          </button>
21        </>
22      )}
23      {state === 'countdown' && (
24        <button onClick={() => send('ABORT')}>PRZERWIJ</button>
25      )}
26      {state === 'error' && (
27        <button onClick={() => send('RETRY')}>Ponów</button>
28      )}
29    </div>
30  );
31}

Nawet gdyby jakiś przycisk wysłał złe zdarzenie, stan się nie zmieni, bo mapa go nie przewiduje. Zauważ jednak, że po ABORT komponent nie pokazuje przycisku RETRY, choć mapa na to pozwala. Takie przeoczenia znikną, gdy przyciski wygenerujesz z samej maszyny:

1function useMachine(machine, initialState) {
2  // ... stan i send jak wyżej
3  const canSend = (event) => Boolean(machine[state]?.[event]);
4  const events = Object.keys(machine[state] ?? {});
5  return { state, send, canSend, events };
6}
7
8function MachineButtons({ events, send }) {
9  return events.map(event => (
10    <button key={event} onClick={() => send(event)}>{event}</button>
11  ));
12}

Hook zwraca teraz obiekt zamiast tablicy, bo wartości jest więcej. canSend przyda się do wyszarzania przycisków, a lista events zawsze zgadza się z mapą.

Dlaczego State Machine jest lepsza niż boolean?

Rozważmy typowy scenariusz bez state machine, znany z formularzy i ekranów ładowania. Mamy komponent z kilkoma stanami boolowskimi:

1// BEZ state machine - "niemożliwe stany" są możliwe
2const [isLoading, setIsLoading] = useState(false);
3const [isError, setIsError] = useState(false);
4const [isSuccess, setIsSuccess] = useState(false);
5
6// Co jeśli isLoading i isError są JEDNOCZEŚNIE true?
7// To "niemożliwy stan", ale nic nie stoi na przeszkodzie!

Trzy flagi dają osiem kombinacji, a sensowne są tylko cztery. Z state machine ten problem znika: komponent jest ZAWSZE w dokładnie jednym stanie i nie może być jednocześnie w stanie loading i error. To jak systemy bezpieczeństwa statku: nie możesz być naraz w trybie lotu i dokowania. Przy rozbudowanych procesach sięgnij po bibliotekę XState, która dodaje m.in. stany zagnieżdżone.

Przypomnienie: useLocalStorage

ThemeProvider ma jedną wadę: po odświeżeniu strony motyw wraca do dark. Pomoże custom hook, który działa jak useState, ale zapisuje wartość w przeglądarce:

1function useLocalStorage(key, initialValue) {
2  const [value, setValue] = useState(() => {
3    const saved = localStorage.getItem(key);
4    return saved !== null ? JSON.parse(saved) : initialValue;
5  });
6
7  useEffect(() => {
8    localStorage.setItem(key, JSON.stringify(value));
9  }, [key, value]);
10
11  return [value, setValue];
12}

Funkcja przekazana do useState to inicjalizator: React wywoła ją tylko przy pierwszym renderze. W providerze wystarczy podmienić jedną linię na useLocalStorage('space-theme', 'dark'), reszta kodu się nie zmienia. Pamiętaj tylko, że na serwerze, na przykład w Next.js, localStorage nie istnieje.

Kiedy używać poszczególnych wzorców?

SytuacjaWzorzec
Współdzielenie danych w drzewieProvider Pattern
Wieloetapowe procesyState Machines
Formularz z walidacjąuseForm hook + State Machine
Lista z filtrowaniemCustom hook + Presentational
Komponent z wariantamiComposition + Props
Wymienne zachowania, np. sposób sortowaniaStrategy: funkcja w propsie
Komunikacja pub/subObserver: EventBus
Złożona konfiguracja składana krok po krokuBuilder
Abstrakcja dostępu do danychRepository: moduł ukrywający fetch

Strategy, Observer i Builder to klasyczne wzorce z książki "Design Patterns" z 1994 roku, a Repository znajdziesz w katalogu wzorców architektury aplikacji Martina Fowlera. Każdy odpowiada na inną potrzebę: kompozycja buduje strukturę, Observer obsługuje komunikację, Strategy wymienia zachowania, Builder składa złożoną konfigurację, a Repository ukrywa dostęp do danych.

Jak wybrać wzorzec?

Tych wzorców nie da się uczciwie ułożyć od najprostszego do najtrudniejszego, bo każdy rozwiązuje inny problem: Observer nie jest "wyższym poziomem" Strategy. Zamiast rankingu trzymaj się procedury, tak jak pilot przed startem trzyma się listy kontrolnej:

  1. Nazwij problem. Co się powtarza, co trudno zmienić, kto musi się dowiedzieć o zmianie?
  2. Sprawdź, czy wystarczą propsy i children. Zwykła kompozycja rozwiązuje zaskakująco wiele.
  3. Wybierz najprostszy wzorzec, który rozwiązuje właśnie ten problem. Tabela wyżej podpowie kandydatów.
  4. Wprowadź go w jednym miejscu i sprawdź, czy kod naprawdę stał się prostszy.
  5. Dopiero potem rozszerz go na resztę aplikacji.

Jeśli w czwartym kroku kod nie stał się prostszy, cofnij zmianę. Wzorzec, który nie rozwiązuje nazwanego problemu, to tylko kolejna warstwa do zrozumienia.

Podsumowanie

Provider Pattern i State Machines to zaawansowane narzędzia, które rozwiązują realne problemy w dużych aplikacjach React. Provider centralizuje dane i logikę, eliminując "prop drilling". State Machine porządkuje złożone przepływy stanów i gwarantuje, że aplikacja nigdy nie znajdzie się w nieokreślonym stanie. Oba wzorce można łączyć: Provider może udostępnić maszynę stanów całemu drzewu, dając każdemu widokowi dostęp do stanu misji i funkcji przejścia. Moja rada: gdy łapiesz się na trzeciej fladze isSomething, narysuj mapę stanów. W projekcie końcowym połączysz wszystkie wzorce z tego modułu.

Pamiętaj: dobra stacja ma jedno źródło zasilania i jasno opisane tryby pracy.

Kod do tej lekcji: App.jsx
1import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
2
3// =============================================
4// WZORZEC PROVIDER
5// =============================================
6const ThemeContext = createContext();
7
8function ThemeProvider({ children }) {
9  const [theme, setTheme] = useState('dark');
10  const toggleTheme = useCallback(() => setTheme(t => t === 'dark' ? 'light' : 'dark'), []);
11  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
12
13  return (
14    <ThemeContext.Provider value={value}>
15      <div className={`theme-${theme}`}>{children}</div>
16    </ThemeContext.Provider>
17  );
18}
19
20function useTheme() {
21  const ctx = useContext(ThemeContext);
22  if (!ctx) throw new Error('useTheme musi być użyty wewnątrz ThemeProvider');
23  return ctx;
24}
25
26// =============================================
27// MASZYNA STANÓW
28// =============================================
29const launchMachine = {
30  idle:       { START_PREFLIGHT: 'preflight' },
31  preflight:  { CHECKS_PASSED: 'countdown', CHECKS_FAILED: 'error' },
32  countdown:  { COUNTDOWN_DONE: 'launching', ABORT: 'aborted' },
33  launching:  { LAUNCH_SUCCESS: 'inFlight', LAUNCH_FAILURE: 'error' },
34  inFlight:   {},
35  error:      { RETRY: 'idle' },
36  aborted:    { RETRY: 'idle' },
37};
38
39// Bieżący stan to ostatni wpis historii: jedna wartość w stanie i czysta funkcja aktualizująca
40function useMachine(machine, initialState) {
41  const [history, setHistory] = useState([initialState]);
42  const state = history[history.length - 1];
43
44  const send = useCallback((event) => {
45    setHistory(h => {
46      const next = machine[h[h.length - 1]]?.[event];
47      return next ? [...h, next] : h;
48    });
49  }, [machine]);
50
51  // Przyciski powstają z dostępnych zdarzeń, jak radzi lekcja
52  const events = Object.keys(machine[state] ?? {});
53  return { state, send, events, history };
54}
55
56// =============================================
57// SKŁADANIE PROVIDERÓW
58// =============================================
59function ComposeProviders({ providers, children }) {
60  return providers.reduceRight(
61    (acc, Provider) => <Provider>{acc}</Provider>,
62    children
63  );
64}
65
66// =============================================
67// KOMPONENTY
68// =============================================
69const themeLabels = { dark: 'ciemny', light: 'jasny' };
70
71function ThemeToggle() {
72  const { theme, toggleTheme } = useTheme();
73  return (
74    <button className="theme-toggle" onClick={toggleTheme}>
75      Motyw: {themeLabels[theme]}
76    </button>
77  );
78}
79
80const stateColors = {
81  idle: '#999', preflight: '#ff9800', countdown: '#ff5722',
82  launching: '#f44336', inFlight: '#4caf50', error: '#f44336', aborted: '#9e9e9e',
83};
84
85const stateLabels = {
86  idle: 'Gotowy do startu', preflight: 'Kontrola przedstartowa', countdown: 'Odliczanie',
87  launching: 'Startujemy...', inFlight: 'W locie!', error: 'Błąd', aborted: 'Przerwano',
88};
89
90// Etykiety i kolory przycisków dla każdego zdarzenia maszyny
91const eventButtons = {
92  START_PREFLIGHT: { label: 'Rozpocznij procedury', color: 'primary' },
93  CHECKS_PASSED: { label: 'Testy zaliczone', color: 'success' },
94  CHECKS_FAILED: { label: 'Testy niezdane', color: 'danger' },
95  COUNTDOWN_DONE: { label: 'Start!', color: 'success' },
96  ABORT: { label: 'PRZERWIJ', color: 'danger' },
97  LAUNCH_SUCCESS: { label: 'Sukces', color: 'success' },
98  LAUNCH_FAILURE: { label: 'Awaria', color: 'danger' },
99  RETRY: { label: 'Ponów', color: 'primary' },
100};
101
102function LaunchControl() {
103  const { state, send, events, history } = useMachine(launchMachine, 'idle');
104
105  return (
106    <div className="launch-control">
107      <h3>Maszyna stanów: sekwencja startu</h3>
108      <div className="state-display" style={{ borderColor: stateColors[state] }}>
109        <span className="state-label" style={{ color: stateColors[state] }}>
110          {stateLabels[state]}
111        </span>
112        <code className="state-id">{state}</code>
113      </div>
114      <div className="action-btns">
115        {events.map(event => (
116          <button key={event} className={`btn btn-${eventButtons[event].color}`} onClick={() => send(event)}>
117            {eventButtons[event].label}
118          </button>
119        ))}
120        {events.length === 0 && <p className="final-note">Stan końcowy: maszyna nie ma już przejść.</p>}
121      </div>
122      <div className="history">
123        <h4>Historia stanów</h4>
124        <div className="history-flow">
125          {history.map((s, i) => (
126            <span key={i} className="history-item" style={{ color: stateColors[s] }}>
127              {stateLabels[s]}{i < history.length - 1 && ' → '}
128            </span>
129          ))}
130        </div>
131      </div>
132    </div>
133  );
134}
135
136// =============================================
137// GŁÓWNA APLIKACJA
138// =============================================
139function AppContent() {
140  return (
141    <div className="app">
142      <div className="top-bar">
143        <h1>Zaawansowane wzorce</h1>
144        <ThemeToggle />
145      </div>
146      <p className="subtitle">Provider Pattern + maszyny stanów</p>
147      <LaunchControl />
148    </div>
149  );
150}
151
152export default function App() {
153  // Przy jednym providerze wystarczyłby zwykły <ThemeProvider>; tu widać sam mechanizm ComposeProviders
154  return (
155    <ComposeProviders providers={[ThemeProvider]}>
156      <AppContent />
157    </ComposeProviders>
158  );
159}

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. Dlaczego custom hook używający kontekstu (np. useTheme) powinien sprawdzać, czy jest użyty wewnątrz providera?

  2. 2. Jaka jest główna zaleta używania state machine zamiast wielu zmiennych boolean?

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

Zadania praktyczne w grze

  • Edytor kodu

    Dokończ centrum powiadomień zbudowane na Provider Pattern. ___BLANK1___: addNotification zwraca nową tablicę z dotychczasowymi powiadomieniami i nowym obiektem { id, message, type } na końcu. ___BLANK2___: removeNotification zostawia wszystkie powiadomienia oprócz tego o podanym id. ___BLANK3___: clearAll ustawia pustą listę. ___BLANK4___: useNotifications rzuca błąd, gdy jest użyty poza NotificationProvider (wtedy useContext zwraca undefined).

  • Układanie w pionie

    Ułóż kroki implementacji Provider Pattern w prawidłowej kolejności:

  • Edytor kodu

    Dokończ maszynę stanów procesu dokowania. ___BLANK1___: po zdarzeniu UNDOCK statek wraca do stanu approaching. ___BLANK2___: send zwraca następny stan z mapy machine[current]?.[event], a gdy w obecnym stanie nie ma przejścia dla tego zdarzenia, zostaje w current. ___BLANK3___: canSend(event) zwraca true albo false zależnie od tego, czy przejście istnieje w obecnym stanie. ___BLANK4___: events to nazwy zdarzeń obiektu przejść obecnego stanu (dla stanu bez wpisu w mapie pusty obiekt, więc pusta lista). DockingControl tworzy z events przyciski.

  • Klikanie w kolejności

    Ułóż definicję przejścia: w stanie idle zdarzenie START przenosi maszynę do stanu running.

  • Układanie w pionie

    Ułóż składnię użycia ComposeProviders:

  • Edytor kodu

    Stwórz hook useLocalStorage(key, initialValue), który działa jak useState, ale pamięta wartość w localStorage. ___BLANK1___: gdy pod kluczem jest zapis, inicjalizator zwraca go zamieniony z JSON z powrotem na wartość (liczba wraca jako liczba). ___BLANK2___: efekt zapisuje pod kluczem bieżącą wartość zamienioną na JSON. ___BLANK3___: efekt uruchamia się ponownie po zmianie klucza albo wartości. Komponent Settings używa hooka dla motywu, języka i rozmiaru tekstu.

  • Układanie w pionie

    Ułóż kroki wyboru wzorca React w kolejności, w jakiej je wykonujesz:

  • Układanie w poziomie

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

  • Klikanie w kolejności

    Kliknij w kolejności etapy, przez które przechodzi zmiana flagi w aplikacji z FeatureFlagProvider:

  • Układanie w pionie

    Ułóż etapy cyklu życia listenera w hooku useEventListener:

  • Układanie w pionie

    Ułóż składnię użycia deklaratywnego komponentu Feature:

  • Klikanie w kolejności

    Kliknij elementy tworzące emisję zdarzenia w EventBus:

  • Układanie w pionie

    Ułóż warstwy wzorca Container/Presentational od najniższej:

  • Układanie w poziomie

    Ułóż składnię renderowania komponentu SpaceCard ze slotami:

  • Układanie w pionie

    Ułóż kroki refaktoryzacji monolitycznego komponentu do Atomic Design:

Przydatne artykuły