Finite-state machine in RxJS
Introduction
Finite-state machine is an abstract machine that can be in exactly one of a finite number of states at any given time. The FSM can change from one state to another in response to its inputs. [1]
Its implementation can be done, for example, by the State pattern from the Behavioral patterns class of the GoF design patterns group. Let’s focus on how to connect these concepts in an event-driven paradigm using RxJS.
Problem
Following simple problem has been described:
Let’s represent the inputs toggle
and hide
as Observables:
const toggle$ = new Subject<void>();
const hide$ = new Subject<void>();
The resulting state is represented as an Observable as well.
In order to persist the state within the Observable, let’s use scan
operator.
Since described automata has only two states, let’s use boolean for its representation.
In order to move between states, distinguish the inputs by string names as they are merged into one stream:
const isShown$ = merge(
toggle$.pipe(map(() => 'toggle')),
hide$.pipe(map(() => 'hide')),
).pipe(scan(
(show, source) => source === 'toggle'
? !show
: false,
false, // Initial state
));
The initial state is false
representing the “Hidden” state.
Abstraction
Yet, we were able to solve automata consisting of two states and two inputs. Let’s abstract this solution to n states. There are multiple ways, how to achieve this abstraction. First way, according to the State pattern, it is possible using classes. Different way is using libraries such as XState.
Let’s use other capabilities of TypeScript instead of classes: string literals. We should start with implementation of the transition function.
type Input = 'toggle' | 'hide';
type State = 'hidden' | 'shown';
type StateTransition = Record<Input, State>;
type TransitionFn = Record<State, StateTransition>;
const transitions: TransitionFn = {
hidden: {
toggle: 'shown',
hide: 'hidden',
},
shown: {
toggle: 'hidden',
hide: 'hidden',
},
};
const initialState: State = 'hidden';
const nextState = (state: State, input: Input): State
=> transitions[state][input];
As seen from the above example, thanks to the typing system, the automata is always complete.
Some refactoring of the inputs:
const toggle$ = new Subject<void>();
const hide$ = new Subject<void>();
const mark = (label: Input) => pipe(map<void, Input>(() => label));
const input$: Observable<Input> = merge(
toggle$.pipe(mark('toggle')),
hide$.pipe(mark('hide')),
);
Finally, let’s adjust the resulting Observable:
const state$: Observable<State> = input$.pipe(
scan(nextState, initialState),
);
In order to fully replace the solution from previous section, output mapper can be used:
const outputMapper: Record<State, boolean> = {
hidden: false,
shown: true,
};
const output$: Observable<boolean> = state$.pipe(
map((state) => outputMapper[state])
);
Due to the selected output function, we can call the machine a Moore type.
Custom Library
Personally, I often use this approach to handle complicated transitions between multiple states. It is easy to configure, simple to debug and fast to adjust. Since I use this approach on several projects, I decided to release it publicly as a npm package.
✅ Unlike the other FSM-oriented packages, this one focuses purely on working with RxJS, is fully typed, minimalistic and built on ESM modules.
➡️ For more information, please refer to https://github.com/jmeinlschmidt/rxjs-fsm.