JavaScript and React course Β· Module 15: Patterns and Architecture
Advanced Patterns - State Machines and Provider Pattern
In this lesson8
The theme switch sits in the header, yet every panel of the station needs to know the colors. The launch procedure has seven stages and several boolean flags that can contradict each other. Now that you know the basic React patterns, it is time for advanced techniques that solve both problems: the Provider Pattern for sharing state and state machines for keeping complex processes under control. In the space station, it is like installing advanced control systems.
Provider Pattern - Central distribution system
The Provider Pattern is an extension of the Context API. We create a dedicated provider that encapsulates both state and logic, and exposes only a ready-made object to components:
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 keeps the same toggleTheme function between renders, and useMemo creates the value object anew only when the theme changes, so consumers do not re-render needlessly. The second half of the pattern is the hook through which components read the context:
1// Custom hook for consumption
2function useTheme() {
3 const context = useContext(ThemeContext);
4 if (!context) {
5 throw new Error('useTheme must be used within ThemeProvider');
6 }
7 return context;
8}createContext() without a default value gives undefined outside the provider, so the hook throws a readable error instead of letting a component run on empty data. The recipe is always the same: a context, a provider with state and logic, a hook for consumption, wrapping the application in the provider and using the hook in components. In React 19 you can also read a context with the use function, and <ThemeContext> itself can act as the provider.
The same recipe gives you the station's notification center. The provider holds a notifications array and three functions that never change the old array, they create a new one instead:
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 appends a new entry with a unique id from the counter, removeNotification uses filter to keep every entry except one, and clearAll puts an empty array in place. The functions wrapped in useCallback do not change, so value is created anew only when the list changes. You write the useNotifications hook just like useTheme, with a readable error outside the provider.
Provider composition
When we have many providers, the tree starts to look like a pyramid, and composition becomes key. Plain nesting looks like this:
1// Instead of nesting:
2function App() {
3 return (
4 <ThemeProvider>
5 <AuthProvider>
6 <NotificationProvider>
7 <AppContent />
8 </NotificationProvider>
9 </AuthProvider>
10 </ThemeProvider>
11 );
12}The ComposeProviders component builds the same pyramid from an array. reduceRight starts from the last provider, so the first one in the array ends up on the outside, just like in the nested version:
1// We can use the 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}The resulting tree is identical, only the notation changed. This trick cannot pass props to specific providers, though, so with three providers I would stick to nesting and keep ComposeProviders for long lists.
State Machines - Controlled state transitions
A state machine eliminates "impossible states" and guarantees that the application is always in one of the defined states. We start with a map: for every state we list the events and the state each one leads to:
1// States and transitions for the launch process
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};We read idle: { START_PREFLIGHT: 'preflight' } like this: in the idle state, the START_PREFLIGHT event takes us to preflight. The inFlight state has no transitions, so it is a final state. Now the hook that guards this map:
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(`No transition for ${event} in state ${current}`);
9 return current;
10 }
11 return nextState;
12 });
13 }, [machine]);
14
15 return [state, send];
16}send looks up the transition in the map, and for an unknown event it stays in the current state and prints a warning in the console. The component only sends events and shows the buttons that match the state:
1// Usage
2function LaunchControl() {
3 const [state, send] = useMachine(launchMachine, 'idle');
4
5 return (
6 <div className="launch-control">
7 <h2>State: {state}</h2>
8 {state === 'idle' && (
9 <button onClick={() => send('START_PREFLIGHT')}>
10 Start Preflight
11 </button>
12 )}
13 {state === 'preflight' && (
14 <>
15 <button onClick={() => send('CHECKS_PASSED')}>
16 Checks Passed
17 </button>
18 <button onClick={() => send('CHECKS_FAILED')}>
19 Checks Failed
20 </button>
21 </>
22 )}
23 {state === 'countdown' && (
24 <button onClick={() => send('ABORT')}>ABORT</button>
25 )}
26 {state === 'error' && (
27 <button onClick={() => send('RETRY')}>Retry</button>
28 )}
29 </div>
30 );
31}Even if some button sent a wrong event, the state would not change, because the map does not allow it. Notice, however, that after ABORT the component shows no RETRY button, even though the map allows it. Such oversights disappear when you generate the buttons from the machine itself:
1function useMachine(machine, initialState) {
2 // ... state and send as above
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}The hook now returns an object instead of an array, because there are more values. canSend will come in handy for graying out buttons, and the events list always matches the map.
Why is a State Machine better than booleans?
Consider a typical scenario without a state machine, familiar from forms and loading screens. We have a component with several boolean states:
1// WITHOUT state machine - "impossible states" are possible
2const [isLoading, setIsLoading] = useState(false);
3const [isError, setIsError] = useState(false);
4const [isSuccess, setIsSuccess] = useState(false);
5
6// What if isLoading and isError are BOTH true at the same time?
7// That's an "impossible state", but nothing prevents it!Three flags give eight combinations, and only four of them make sense. With a state machine this problem disappears: the component is ALWAYS in exactly one state and cannot be in the loading and error states at the same time. It is like the safety systems of a ship: you cannot be in flight mode and docking mode at once. For elaborate processes, reach for the XState library, which adds nested states, among other things.
Reminder: useLocalStorage
ThemeProvider has one flaw: after a page refresh the theme goes back to dark. A custom hook that works like useState but saves the value in the browser will help:
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}The function passed to useState is an initializer: React calls it only on the first render. In the provider, it is enough to replace one line with useLocalStorage('space-theme', 'dark'), and the rest of the code does not change. Just remember that localStorage does not exist on the server, for example in Next.js.
When to use specific patterns?
| Situation | Pattern |
|---|---|
| Sharing data in the tree | Provider Pattern |
| Multi-step processes | State Machines |
| Form with validation | useForm hook + State Machine |
| List with filtering | Custom hook + Presentational |
| Component with variants | Composition + Props |
| Interchangeable behaviors, e.g. the sorting method | Strategy: a function in a prop |
| Pub/sub communication | Observer: EventBus |
| Complex configuration assembled step by step | Builder |
| Abstraction of data access | Repository: a module that hides fetch |
Strategy, Observer and Builder are classic patterns from the 1994 book "Design Patterns", and you will find Repository in Martin Fowler's catalog of application architecture patterns. Each one answers a different need: composition builds the structure, Observer handles communication, Strategy swaps behaviors, Builder assembles a complex configuration, and Repository hides data access.
How to choose a pattern?
These patterns cannot honestly be ranked from the simplest to the hardest, because each one solves a different problem: Observer is not a "higher level" of Strategy. Instead of a ranking, follow a procedure, the way a pilot follows a checklist before launch:
- Name the problem. What repeats, what is hard to change, who needs to find out about a change?
- Check whether props and
childrenare enough. Plain composition solves a surprising amount. - Pick the simplest pattern that solves exactly this problem. The table above suggests candidates.
- Introduce it in one place and check whether the code really got simpler.
- Only then extend it to the rest of the application.
If the code did not get simpler in the fourth step, roll the change back. A pattern that does not solve the named problem is just one more layer to understand.
Summary
The Provider Pattern and State Machines are advanced tools that solve real problems in large React applications. The Provider centralizes data and logic, eliminating "prop drilling". The State Machine organizes complex state flows and guarantees that the application never ends up in an undefined state. Both patterns can be combined: a Provider can expose a state machine to the whole tree, giving every view access to the mission state and the transition functions. My advice: when you catch yourself adding a third isSomething flag, draw a state map. In the final project you will combine all the patterns from this module.
Remember: a good station has one power source and clearly described operating modes.
Code for this lesson: App.jsx
1import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
2
3// =============================================
4// PROVIDER PATTERN
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 must be used within ThemeProvider');
23 return ctx;
24}
25
26// =============================================
27// STATE MACHINE
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// The current state is the last history entry: one value in state and a pure updater function
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 // Buttons are generated from the available events, as the lesson recommends
52 const events = Object.keys(machine[state] ?? {});
53 return { state, send, events, history };
54}
55
56// =============================================
57// COMPOSE PROVIDERS
58// =============================================
59function ComposeProviders({ providers, children }) {
60 return providers.reduceRight(
61 (acc, Provider) => <Provider>{acc}</Provider>,
62 children
63 );
64}
65
66// =============================================
67// COMPONENTS
68// =============================================
69const themeLabels = { dark: 'dark', light: 'light' };
70
71function ThemeToggle() {
72 const { theme, toggleTheme } = useTheme();
73 return (
74 <button className="theme-toggle" onClick={toggleTheme}>
75 Theme: {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: 'Ready', preflight: 'Preflight Checks', countdown: 'Countdown',
87 launching: 'Launching...', inFlight: 'In Flight!', error: 'Error', aborted: 'Aborted',
88};
89
90// Button labels and colors for every machine event
91const eventButtons = {
92 START_PREFLIGHT: { label: 'Start Preflight', color: 'primary' },
93 CHECKS_PASSED: { label: 'Checks Passed', color: 'success' },
94 CHECKS_FAILED: { label: 'Checks Failed', color: 'danger' },
95 COUNTDOWN_DONE: { label: 'Go!', color: 'success' },
96 ABORT: { label: 'ABORT', color: 'danger' },
97 LAUNCH_SUCCESS: { label: 'Success', color: 'success' },
98 LAUNCH_FAILURE: { label: 'Failure', color: 'danger' },
99 RETRY: { label: 'Retry', 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>State Machine: Launch Sequence</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">Final state: the machine has no more transitions.</p>}
121 </div>
122 <div className="history">
123 <h4>State History</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// MAIN APP
138// =============================================
139function AppContent() {
140 return (
141 <div className="app">
142 <div className="top-bar">
143 <h1>Advanced Patterns</h1>
144 <ThemeToggle />
145 </div>
146 <p className="subtitle">Provider Pattern + State Machines</p>
147 <LaunchControl />
148 </div>
149 );
150}
151
152export default function App() {
153 // With one provider a plain <ThemeProvider> would do; here you see the ComposeProviders mechanism itself
154 return (
155 <ComposeProviders providers={[ThemeProvider]}>
156 <AppContent />
157 </ComposeProviders>
158 );
159}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Why should a custom hook using context (e.g., useTheme) check whether it is used inside a provider?
2. What is the main advantage of using a state machine instead of multiple boolean variables?
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Finish the notification centre built on the Provider Pattern. ___BLANK1___: addNotification returns a new array with the existing notifications and the new object { id, message, type } at the end. ___BLANK2___: removeNotification keeps every notification except the one with the given id. ___BLANK3___: clearAll sets an empty list. ___BLANK4___: useNotifications throws an error when it is used outside NotificationProvider (useContext then returns undefined).
- Vertical ordering
Arrange the Provider Pattern implementation steps in the correct order:
- Code editor
Finish the state machine of the docking process. ___BLANK1___: after the UNDOCK event the ship goes back to the approaching state. ___BLANK2___: send returns the next state from the map, machine[current]?.[event], and when the current state has no transition for this event, it stays in current. ___BLANK3___: canSend(event) returns true or false depending on whether the transition exists in the current state. ___BLANK4___: events are the event names of the transition object of the current state (for a state missing from the map, an empty object, so an empty list). DockingControl creates buttons from events.
- Click in order
Arrange the transition definition: in the idle state the START event moves the machine to the running state.
- Vertical ordering
Arrange the syntax for using ComposeProviders:
- Code editor
Create a useLocalStorage(key, initialValue) hook that works like useState but remembers the value in localStorage. ___BLANK1___: when there is a saved entry under the key, the initializer returns it converted from JSON back into a value (a number comes back as a number). ___BLANK2___: the effect saves the current value converted to JSON under the key. ___BLANK3___: the effect runs again after the key or the value changes. The Settings component uses the hook for the theme, the language and the text size.
- Vertical ordering
Arrange the steps of choosing a React pattern in the order you take them:
- Horizontal ordering
Arrange the syntax for rendering a polymorphic component with the as prop:
- Click in order
Click in order the stages a flag change goes through in an app with FeatureFlagProvider:
- Vertical ordering
Arrange the stages of a listener's lifecycle in the useEventListener hook:
- Vertical ordering
Arrange the syntax for using the declarative Feature component:
- Click in order
Click the elements that form an event emission in EventBus:
- Vertical ordering
Arrange the layers of the Container/Presentational pattern from lowest:
- Horizontal ordering
Arrange the syntax for rendering a SpaceCard component with slots:
- Vertical ordering
Arrange the steps for refactoring a monolithic component to Atomic Design: