Interfejs, który raz pokazuje spinner, raz komunikat błędu, a innym razem dane, szybko zamienia się w plątaninę warunków. Model state machine porządkuje takie zachowanie, ponieważ jasno opisuje dostępne stany, zdarzenia oraz przejścia między nimi. Pokażę, jak wykorzystać maszyny stanów w architekturze aplikacji webowych, kiedy zastępują serię instrukcji if, a kiedy lepiej sięgnąć po prostsze rozwiązanie.
Maszyna stanów zamienia niejasną logikę w przewidywalny przepływ
- Stan opisuje aktualny tryb działania komponentu lub procesu.
- Zdarzenie wywołuje próbę przejścia do kolejnego stanu.
- Przejście określa, co wolno zrobić z danego stanu i przy konkretnym zdarzeniu.
- Warunki ochronne blokują przejście, gdy dane lub uprawnienia są niepoprawne.
- Największą korzyścią jest przewidywalność, testowalność i łatwiejsze debugowanie.
Czym jest maszyna stanów i po co stosować ją w aplikacji
Maszyna stanów to model obliczeniowy, który w danym momencie znajduje się w jednym z określonego, skończonego zbioru stanów. Reaguje na zdarzenia, a następnie zgodnie z ustalonymi regułami pozostaje w bieżącym stanie albo przechodzi do innego.
Dobrym przykładem jest formularz logowania. Nie wystarczy informacja, że formularz jest „aktywny”. W praktyce może być w stanie idle, submitting, success albo error. Każdy z nich ma inne dozwolone działania. Podczas wysyłania nie chcę uruchamiać kolejnego żądania, a po sukcesie nie powinien pojawić się przycisk ponownej wysyłki.
Właśnie tutaj zwykłe zmienne typu isLoading, hasError i isLoggedIn zaczynają się ze sobą mieszać. Po kilku zmianach łatwo uzyskać niemożliwą kombinację, na przykład jednocześnie aktywny spinner i komunikat sukcesu. Jawnie opisany model ogranicza takie przypadki, bo definiuje dozwolone konfiguracje, zamiast pozwalać aplikacji tworzyć je przypadkiem.
W swojej pracy traktuję maszynę stanów przede wszystkim jako narzędzie architektoniczne, a nie bibliotekę do importowania. Najpierw rozpisuję zachowanie na kartce lub w diagramie, dopiero później decyduję, czy wystarczy kilka funkcji, czy potrzebny będzie rozbudowany silnik.
Z jakich elementów składa się ten model
Najprostsza maszyna ma cztery składniki. Są nimi stany, zdarzenia, przejścia i stan początkowy. W większych rozwiązaniach dochodzą jeszcze akcje, warunki ochronne oraz dane kontekstowe.
| Element | Znaczenie | Przykład w aplikacji |
|---|---|---|
| Stan | Aktualny tryb działania systemu | loading |
| Zdarzenie | Sygnał, który może zmienić stan |
SUBMIT, RETRY
|
| Przejście | Reguła wyboru kolejnego stanu | loading + SUCCESS = success |
| Guard | Warunek dopuszczający przejście | poprawny token sesji |
| Akcja | Efekt wykonywany przy zmianie lub wejściu do stanu | zapisanie danych użytkownika |
| Kontekst | Dane pomocnicze niezastępujące stanu | liczba prób, komunikat błędu |
Trzeba rozdzielić stan od danych kontekstowych. Informacja error może być stanem, natomiast treść komunikatu, kod odpowiedzi HTTP czy liczba prób należą do kontekstu. Taki podział poprawia czytelność, bo nie tworzę osobnego stanu dla każdego możliwego tekstu błędu.
Przejście nie powinno być rozumiane jako dowolna zmiana wartości. To jawna reguła. Jeżeli aplikacja jest w stanie success, zdarzenie SUBMIT może nie robić nic albo prowadzić do loading, ale taka decyzja musi wynikać z modelu, a nie z przypadkowego efektu ubocznego komponentu.
Przykład minimalnej implementacji w JavaScript
const transitions = {
idle: {
SUBMIT: "loading"
},
loading: {
SUCCESS: "success",
FAILURE: "error"
},
error: {
RETRY: "loading"
},
success: {}
};
function transition(currentState, event) {
return transitions[currentState]?.[event] ?? currentState;
}
let state = "idle";
state = transition(state, "SUBMIT"); // loading
state = transition(state, "SUCCESS"); // success
Ten przykład jest celowo prosty. Funkcja transition nie wykonuje żądań HTTP ani nie zmienia interfejsu. Wyłącznie wylicza kolejny stan, dzięki czemu można ją testować jako czystą funkcję. Efekty uboczne, takie jak pobieranie danych, powinny znajdować się obok modelu, a nie ukrywać w każdej możliwej gałęzi warunku.

Jak zaprojektować maszynę stanów dla aplikacji webowej
Projektowanie zaczynam od zdarzeń, które rzeczywiście mogą wystąpić, a nie od nazw komponentów. W formularzu płatności będą to na przykład SUBMIT, PAYMENT_SUCCESS, PAYMENT_FAILED i CANCEL. Dopiero potem ustalam, w jakich stanach te zdarzenia są obsługiwane.
- Opisz proces od początku do końca. Zapisz, co dzieje się przed działaniem użytkownika, w trakcie operacji i po jej zakończeniu.
- Wypisz skończony zestaw stanów. Każdy stan powinien mieć konkretne znaczenie i wpływać na zachowanie aplikacji.
- Zdefiniuj zdarzenia. Oddziel kliknięcia, odpowiedzi API, upływ czasu i błędy sieciowe.
- Rozrysuj dozwolone przejścia. Sprawdź, czy każde przejście ma sens i czy nie brakuje ścieżki awaryjnej.
- Dodaj warunki oraz efekty. Guard powinien sprawdzać regułę, a akcja wykonywać konkretną operację.
- Przetestuj nie tylko sukces. Uwzględnij anulowanie, ponowienie, timeout, odświeżenie strony i wielokrotne kliknięcie.
Przy aplikacjach webowych szczególnie dobrze działa rozdzielenie trzech warstw. Pierwsza opisuje model przejść, druga obsługuje efekty zewnętrzne, a trzecia renderuje interfejs. Dzięki temu komponent Reacta, Vue czy zwykły moduł DOM nie musi samodzielnie odtwarzać reguł biznesowych.
Przykładowy przepływ zamówienia może wyglądać tak: cart przechodzi do pending po potwierdzeniu zakupu, a potem do paid lub payment_failed. Nie pozwalam przejść z cart bezpośrednio do paid, ponieważ płatność musi mieć potwierdzenie z systemu zewnętrznego. Taka reguła jest prostsza do zauważenia na diagramie niż w rozproszonym kodzie.
W większych przepływach przydają się stany hierarchiczne. Stan editing może mieć podstany draft, validating i saving. To ogranicza liczbę połączeń na diagramie, ale nie powinno służyć do ukrywania zbyt skomplikowanej logiki. Jeżeli diagram staje się nieczytelny, dzielę model na mniejsze maszyny komunikujące się zdarzeniami.
Kiedy użyć maszyny stanów zamiast if/else lub reduktora
Nie każdy komponent potrzebuje formalnej maszyny. Dla prostego przełącznika światła dwie wartości i jedna funkcja mogą być wystarczające. Rozwiązanie zaczyna zyskiwać przewagę wtedy, gdy pojawiają się co najmniej trzy lub cztery znaczące stany, asynchroniczne operacje oraz wiele możliwych ścieżek błędu.
| Podejście | Kiedy pasuje | Ograniczenie |
|---|---|---|
if/else |
Krótka, lokalna logika | Szybko tworzy trudną do kontroli sieć warunków |
| Booleany i zmienne | Jedna niezależna informacja | Mogą tworzyć sprzeczne kombinacje |
| Reducer | Przewidywalne aktualizowanie stanu aplikacji | Nie zawsze jasno pokazuje dozwolone przejścia |
| Maszyna stanów | Procesy, formularze, sesje, płatności, wieloetapowe UI | Wymaga zaprojektowania modelu i może być przesadą przy prostych komponentach |
| Statechart | Duże modele z hierarchią, równoległością i historią | Większy narzut poznawczy oraz narzędziowy |
Reducer odpowiada głównie na pytanie, jak wyliczyć nowy stan po zdarzeniu. Maszyna stanów idzie krok dalej i opisuje, które przejścia są legalne. Oba podejścia można łączyć, ale nie warto budować rozbudowanego modelu tylko dlatego, że dana biblioteka jest popularna.
W ekosystemie JavaScript często spotyka się XState, który pozwala deklarować stany, zdarzenia, guardy, akcje i usługi asynchroniczne. Narzędzie jest sensowne przy złożonych przepływach, zwłaszcza gdy zespół chce wizualizować model i śledzić zdarzenia. Dla jednego przycisku z trzema stanami prosty obiekt przejść będzie zwykle tańszy w utrzymaniu.
Praktyczne zastosowania w projektach webowych
Logowanie i sesja użytkownika
Sesję można opisać stanami logged_out, checking, logged_in i session_expired. Dzięki temu aplikacja nie wyświetli panelu użytkownika przed zakończeniem sprawdzania tokenu, a wygaśnięcie sesji będzie osobnym zdarzeniem, a nie przypadkową zmianą kilku flag.
Formularze i walidacja
Formularz rejestracji często ma stany editing, validating, submitting, success i error. Oddzielenie walidacji lokalnej od wysyłania danych pozwala uniknąć sytuacji, w której przycisk pokazuje „wysyłanie”, mimo że formularz nie przeszedł podstawowych kontroli.
Ładowanie danych z API
Najbardziej typowy przepływ to idle, loading, loaded i failed. Dla bardziej wymagających ekranów dodaję jeszcze refreshing, aby odświeżanie nie zasłaniało użytkownikowi wcześniej pobranych danych. To drobna różnica, ale mocno poprawia odbiór interfejsu.
Przeczytaj również: Wzorce projektowe PHP - czy na pewno ich potrzebujesz?
Proces zamówienia
W sklepie internetowym osobne stany mogą reprezentować cart, checkout, payment_pending, paid, cancelled i refunded. Model pomaga pilnować, aby zwrot był dostępny dopiero po opłaceniu zamówienia, a anulowanie nie uruchamiało się po jego wysłaniu do realizacji.
Te przykłady łączy jedna zasada: stan opisuje tryb działania procesu, a nie wygląd pojedynczego elementu. Jeżeli kilka komponentów musi znać tę samą regułę, maszyna powinna znajdować się bliżej domeny aplikacji niż konkretnego widoku.
Najczęstsze błędy i granice tego podejścia
Pierwszy błąd to tworzenie stanów na podstawie każdej zmiennej. hasModal, isLoading i hasError nie zawsze powinny być trzema niezależnymi stanami. Najpierw pytam, czy opisują różne tryby procesu, czy tylko dane, które można przechowywać w kontekście.
Drugi problem pojawia się wtedy, gdy przejścia wykonują zbyt wiele pracy. Funkcja odpowiedzialna za zmianę stanu nie powinna jednocześnie wysyłać kilku żądań, modyfikować DOM-u i zapisywać danych w magazynie. Lepiej oddzielić decyzję o kolejnym stanie od efektu, który ta decyzja uruchamia.
Trzeci błąd to brak obsługi zdarzeń niemożliwych lub spóźnionych. Odpowiedź API może przyjść po anulowaniu formularza, a użytkownik może kliknąć przycisk dwa razy w ciągu kilkudziesięciu milisekund. Model powinien określać, czy takie zdarzenie jest ignorowane, prowadzi do błędu, czy pozostaje bezpieczne.
Maszyna stanów nie rozwiązuje też automatycznie problemów z trwałością danych, współbieżnością ani komunikacją między usługami. Jeżeli stan ma przetrwać odświeżenie, trzeba go zapisać i poprawnie odtworzyć. Jeżeli kilka procesów zmienia ten sam rekord, potrzebne będą dodatkowe mechanizmy, na przykład blokady, wersjonowanie lub idempotencja.
Uważam też, że diagram nie jest dowodem poprawności. Może wyglądać bardzo profesjonalnie, a mimo to pomijać timeout, brak sieci albo ponowienie operacji. Największą wartość daje dopiero połączenie modelu z testami przejść, w których sprawdzam każdy ważny stan i każde zdarzenie.
Jak zacząć bez komplikowania projektu
Weź jeden konkretny proces, najlepiej taki, który już ma kilka flag i trudnych do wyjaśnienia warunków. Wypisz jego stany na kartce, zaznacz zdarzenia oraz usuń przejścia, które nie mają uzasadnienia biznesowego. Dopiero wtedy przenieś model do kodu.
Na początek wystarczy zwykła mapa przejść i funkcja wyliczająca następny stan. Bibliotekę warto dodać dopiero wtedy, gdy potrzebujesz hierarchii, równoległych regionów, wizualizacji, historii albo współdzielenia modelu przez większy zespół.
Dobrze zaprojektowana maszyna stanów nie ma za zadanie zwiększać liczby plików. Ma sprawić, że po spojrzeniu na model wiadomo, co może wydarzyć się teraz, co jest niemożliwe i jak system zareaguje na błąd. To właśnie ta przewidywalność zwykle daje większą korzyść niż sama elegancja kodu.