I’m currently implementing job resubmission in Jobson UI and found that state machines greatly simplify the code needed to render a user workflow.
Background
A large amount of Jobson UI’s codebase is dedicated to dynamically generating input forms at runtime.
Generating the relevant <input>
, <select>
, <textarea>
s,
etc. from a Jobson job spec is
fairly easy (see createUiInput
here)
but became increasingly complex after adding job copying because extra
checks needed to be made:
- Is the job “fresh” or “based on an existing job”?
- Was there a problem loading the existing job?
- Did the existing job load OK but can’t be coerced into the live version of the job spec?
- Did the user, on being informed of the coercion issue, decide to start a fresh spec or make a “best attempt” at coercion?
- etc.
Each of these conditions are simple to check in isolation but, when combined, result in delicate state checks:
render() {
if (this.state.isLoadingSpecs)
return this.renderLoadingSpecsMessage();
if (this.state.errorLoadingSpecs)
return this.renderSpecsLoadingError();
else if (this.state.isLoadingExistingJob)
return this.renderLoadingExistingJob();
else if (this.state.errorLoadingExistingJob)
return this.renderErrorLoadingExistingJob();
else if (this.state.isCoercingAnExistingJob)
// etc. etc.
}
These checks were cleaned up slightly by breaking things into smaller components. However, that didn’t remove the top-level rendering decisions altogether.
For example, the isLoadingSpecs
and errorLoadingSpecs
checks can
put into a standalone <SpecsSelector />
component that emits
selectedSpec
s. However, the top level component
(e.g. <JobSubmissionComponent />
) still needs to decide what to
render based on emissions from multiple child components (e.g. it
would need to decide whether to even render <SpecsSelector />
at
all).
State Machines to the Rescue
What ultimately gets rendered in these kind of workflows depends on a
complex combination of flags because only state, rather than state
and transitions are being modelled. The example above compensates
for a lack of transition information by ordering the if
statements:
isLoadingSpecs
is checked before isLoadingExistingJob
because one
“happens” before the other.
This problem—a lack of transition information—is quite
common. Whenever you see code that contains a big block of if..else
statements, or an ordered lookup table, or a switch
on a step-like
enum, that’s usually a sign that the code might be trying to model a
set of transitions between states. Direct examples can be found in
many network data parsers (e.g. websocket frame and HTTP parsers)
because the entire payload (e.g. a frame) isn’t available in one
read()
call, so the parser has to handle intermediate parsing
states (example from Java jetty).
State Machines (SMs) represent states and transitions. For example, here’s the Jobson UI job submission workflow represented by an SM:
From a simplistic point of view, SMs follow simple rules:
- The system can only be in one state at a given time
- There are a limited number of ways to transition to another state
I initially played with the idea of using SMs in ReactJs UIs after exploring SM implementations of network parsers. I later found the idea isn’t new. A similar (ish) post by Jeb Beich has been posted on cogninet here and contains some good ideas, but his approach is purer (it’s data-driven) and is implemented in ClojureScript (which I can’t use for JobsonUI). By comparison, this approach I used focuses on using callbacks to transition so that individual states can be implemented as standard ReactJS components. In the approach:
-
A state is represented by a component. Components can, as per the ReactJS approach, have their own internal state, events, etc. but the top-level state (e.g “editing job”) is represented by that sole component (e.g.
EditingJobStateComponent
) -
A component transitions to another state by calling a callback with the next “state” (e.g.
transitionTo(SubmittingJobStateComponent)
). -
A top-level “state machine renderer” is responsible for rendering the latest component emitted via the callback.
This slight implementation change means that each component only has
to focus on doing its specific job (e.g. loading job specs) and
transitioning to the next immediate step. There is no “top-level”
component containing a big block of if..else
statements.
Code Examples
A straightforward implementation involves a top-level renderer with no decision logic. Its only job is to render the latest component emitted via a callback:
export class StateMachineRenderer extends React.Component {
constructor() {
const initialComponent =
React.createElement(InitialStateComponent, {transitionTo: this.handleTransition.bind(this)});
this.state = {
component: initialComponent,
};
}
handleStateTransition(nextComponent) {
this.setState({component: nextComponent});
}
render() {
return this.state.component;
}
}
A state is just a standard component that calls transitionTo
when it
wants to transition. Sometimes, that transition might occur
immediately:
export class InitialState extends React.Component {
componentWillMount() {
const props = {transitionTo: this.props.transitionTo};
let nextComponent;
if (jobBasedOnExistingJob) {
nextComponent = React.createElement(LoadExistingJobState, props, null);
} else {
nextComponent = React.createElement(StartFreshJobState, props, null);
}
this.props.transitionTo(nextComponent);
}
}
Otherwise, it could be after a set of steps:
export class EditingJobState extends React.Component {
// init etc.
onUserClickedSubmit() {
this.props.api.submitJob(this.state.jobRequest)
.then(this.transitionToJobSubmittedState.bind(this))
.catch(this.showErrors.bind(this))
}
transitionToJobSubmittedState(jobIdFromApi) {
const component = React.createElement(JobSubmittedState, {jobId: jobIdFromApi}, null);
this.props.transitionTo(component);
}
}
Either way, this simple implementation seems to work fine for quite complex workflows, and means that each components only contains a limited amount of “transition” logic, resulting in a cleaner codebase.
Conclusions
This pattern could be useful to webdevs that find themselves tangled
in state- and sequence-related complexity. I’ve found SMs can
sometimes greatly reduce overall complexity (big blocks of
if..else
, many state flags) at the cost of a little local
complexity (components need to handle transitions).
However, I don’t reccomend using this pattern everywhere: it’s usually easier to use the standard approaches up to the point of standard approaches being too complex. If your UI involves a large, spiralling, interconnected set of steps that pretty much require a mess of comparison logic though, give this approach a try.