Simpler UI Logic With Finite State Machines
terodox.tech
terodox.tech
State machines are great but they get quite complicated when you have two machines interacting at some point. I find this happens quite a bit for complicated UIs, like a data grid with filter and search controls, in-row controls, a preview pane on the side, etc. Things that can be reasonably simple become overly complicated as you try to cram everything into a state machines model.
It's hard to give concrete examples... but it happens often. I even find myself doing it.
Rust enums (sum types) are really good for doing FSMs, as they:
- Allow you to add fields to each state, which makes it easier to understand what each state is for at a glance.
- Prevent you from accessing fields for a different state at the wrong time.
- Make it easy to do nested states, without that nestedness existing for states that don't need it.
- Provide exhaustiveness warnings for if you're not handling a case.
I do wish more languages had sum types.
Even before you get to a data grid, you get an exponential explosion of states. Since most FSMs are not a part of the actual UI frameworks, and are and addon on top of that, you end up with just so much stuff.
I had a rather simple checkout form in the form of "list ticket types, when clicked select number of people, click `reserve`". Sounds like a few states, right? Until you throw in "is user logged in?", "is the user VIP" (requires adding some other tickets with their states), "do we require user consent?", "do we require user info?", "can user use their bonus points?", "is there a min/max amount of tickets allowed?" etc. etc. etc.
Suddenly you have an explosion of states and validations between these states. It's no longer A -> B. It's A -> (if X then B else if then Y C else if Z ерут В) for many if not all state transitions.
And that's just to show screens in the checkout process. Now let's talk about form controls and fields on each page... :)
One mental model that helps reduce this load, that you alluded to, is avoiding product types where possible in favor of sum types.
Eg. Imaging you have a type describing React props for some component: {a?: number} and {a: number} | {} seem very similar on the surface — they both describe an object with a single field that may or may not be defined. In both cases you have 2 possible states.
But as soon as you add another field, the number of states can grow quickly unless you’re thoughtful: {a?: number, b?: string} has 4 states, while the model you might have been trying to describe — {a: number} | {b: string} — still has just two.
The less states you have, the easier it is to reason about and automatically verify your model. But in practice, people often accidentally introduce product type where they meant to use sums. I see this with Flow and TypeScript-typed React code all the time.
The problem I've run into with the second approach as the number of states expand is 1 - you end up with tons of boilerplate to discriminate between types, and 2 - it becomes hard to treat objects polymorphically.
I sometimes end up doing {a: number, b?: undefined} | {b: string, a?: undefined} so that I can write `if (obj.a) doSomething()`, but it obviously gets unwieldy with a lot of properties and states. Imo it would be nice if TS did this automatically so that I could check for the existence of any property that might be defined on an object.
{type: 'a', a: number} | {type: 'b', b: string}
When you do this you consume it with a switch(x.type) statement, not an if() statement, so that you can write `default: assertImpossible(x.type)` for some function `assertImpossible(y: never): never` that throws an exception of your choice.This gives clear fail-at-runtime semantics if JS hands you some crap your types weren’t anticipating, but also fail-at-compile-time semantics if your pattern match is no longer exhaustive (i.e. you added a new case, now TypeScript tells you everything you need to change).
In general it's really nice to view any data structure as isomorphic to the control structure that consumes it. “lists” are consumed by for..of loops, “dicts” are consumed by for..of Object.keys() loops, sum types are consumed by switches, objects are consumed by property accesses, etc.
type Props =
| { type: 'a', a: number }
| { type: 'b', b: string }
function f(props: Props): number {
switch (props.type) {
case 'a': return 1
case 'b': return 2
}
}
Unfortunately you're right that this breaks when using `if` instead of `switch` (unless you have a default return at the end of your function, or replace one of your `if`s with an `else`; Playground link: [1]): type Props =
| { type: 'a', a: number }
| { type: 'b', b: string }
// Error TS2366: Function lacks ending return statement and return
// type does not include 'undefined'.
function f(props: Props): number {
if (props.type === 'a') {
return 3
}
if (props.type === 'b') {
return 4
}
}
This second case is a bug in TSC, and is tracked in https://github.com/microsoft/TypeScript/issues/18319.[0] https://www.typescriptlang.org/play/#code/AQ4FwTwBwU2AFATgey...
[1] https://www.typescriptlang.org/play/#code/AQ4FwTwBwU2AFATgey...
Here's an example (Playground link: [1]):
import * as React from 'react'
type Props =
| {a: number}
| {b: string}
function MyComponent(props: Props): JSX.Element {
if ('a' in props) {
return <div>a is {props.a}</div>
}
return <div>b is {props.b}</div>
}
let x = <MyComponent a={3} />
let y = <MyComponent b="foo" />
[0] https://www.typescriptlang.org/docs/handbook/release-notes/t...[1] https://www.typescriptlang.org/play/?jsx=2#code/JYWwDg9gTgLg...
https://alisoftware.github.io/swift/pattern-matching/2016/05...
As does Flow: https://flow.org/blog/2015/07/03/Disjoint-Unions/
https://gist.github.com/frankpf/cde7f792580f731dfe886ed2d91b... (see the second file for usage)
Definitely a fair point that it is a few more keystrokes, though.
I suppose this depends a bit on how transitions are triggered.
If an ux even can trigger a change in state machine a, from state a1 to a2, and that transition can fire off an event that trigger a transition in b, from b1 to b2, and that transition fires an event that triggers a transition from a1.... Then you have problems.
It would probably be better to model a and b as a bigger machine c, that models all states and transitions, and allow the various components/widgets to be "views" of this machine c?
Or; make sure that a state transition won't in and of itself fire an event that triggers a transition in another state machine. Ie: a and could both respond to window resize, but should not trigger a window resize.
I've recently started using Fulcro (framework in ClojureScript), where they are first class citizens in the form of "UI state machines."
http://book.fulcrologic.com/#_ui_state_machines
In retrospect it's obvious how much better it is to contain, for example, the logic of logging in and signing up within an FSM.
SMC (State Machine Compiler) is worth bringing up here, which uses a formal language to define a state machine and produces code in one of a dozen+ languages to run it. Makes it impossible to build a bad state machine. I remember using this on some small projects last decade, I'm happily surprised to see it's still maintained.
http://smc.sourceforge.net/ and code at: https://sourceforge.net/p/smc/git-code/ci/master/tree/
Thank you!
I read the article, and then beyond, and I still don't know what in tarnation distinguishes an FSM from a simple state. Every program has a set of states and transitions. Is the difference simply that in an FSM the programmer has modeled both sets explicitly? And if so, what is the model -- a map from states to available transitions?
The statechart solves this problem by using a representation that allows both OR and AND avoiding the state explosion. Multiple states can be combined in a super-state, which allows to model common properties of the enclosed states, provided that the internal substates are XOR-ed, i.e. only one of them can be active in a given time. The advantage of the super-states is that they allow the specifier of the system to proceed in a top-down manner by specifying iteratevily the complete behaviour of the system. These super-states can also conjuncted, to model the interactions between the systems and to represent their parallelism. This conjunction creates implicit interactions between the two subsystems, which substitute the need to create the product FSM.
Both FSM and statecharts can represent the same behaviour of a system, but they differ in how easily understandable their representation can be for complex systems.
Well, FSM is a basic CS concept.
>Every program has a set of states and transitions. Is the difference simply that in an FSM the programmer has modeled both sets explicitly? And if so, what is the model -- a map from states to available transitions?
Yes, and actions on each transition.
Fulcro is doing so much innovation in the front end space that goes completely ignored by the wider community because lisp is hard to read and types are the be all end all of programming
For someone who is versed in Clojure, what is Fulcro doing in the frontend space that is so innovative?
And the compiler would further ensures that you are handling every possible state at every level.
One problem I've often encountered with UI state machines is the explosion in error states, and the "proper" handling of invalid state transitons (unexpected operations) not from a technical perspective but from a UI perspective.
> A simpler approach would be to have a single variable for state, and only allows switching between well known states!
Was not reflected in the code, since it's possible to transition directly from "error" to "success" without going through "submitted".
Which makes me wonder: Does anyone know whether there's existing art for proving UIs using something like TLA+? Sounds like in interesting match, if your UI is following a FSM pattern.
See e.g. [2] for more on one of the Petri-net-based formalisms they're using.
[1] https://www.irit.fr/recherches/ICS/
[2] https://www.irit.fr/recherches/ICS/softwares/petshop/ico.htm...
I've tried this "state machine" approach but the problem with designing a React/UI component to respond to a single "state" value like this is that it's not uncommon to end up wanting _more_ states like "has errors and loading", or "has data and loading". At which point you find you're building lots of similar looking subcomponents which get torn apart every time you add a new attribute to the combination. Sometimes it's easier to deal with the combinatorial number of states by passing in the attributes separately and deal with the combinations of state with if/ternary statements, exactly like in the original example.
Plus you don't have to have the switch statement argument with someone for whom not using them was the only thing they remember from Uncle Bob's Clean Code :grin:
https://www.mathworks.com/help/stateflow/ug/analog-trigger-a...
It's used in calling AI for bruteforcing possible actions according to a state that a NPC can use.
I recommend to read it/check it out, the AI of FEAR ( which is excellent) is based on it.
Represent your state space as a graph and run A* over it to find a “path” (or plan) from starting state to goal state. A simple heuristic to use for the A* distance heuristic (which I saw in one of the gamedev talks, I don’t remember if in FEAR but its likely it was) is to simply count how many variables are correct and use that count as the distance heuristic (they used simple boolean variables, if you use numeric values it may be harder, but I suppose you could still just count how many goal variables are met).
I also attempted to use prolog-style logic programming (in clojure’s core.logic) to search for a plan, but I never finished it. I guess I overcomplicated it by being too ambitious. I wanted the actions to be able to have preconditions like “have at least N of X” and the goal to have similar conditions and have the planner keep track of counts. It worked to a degree but I never finished it because it started getting too complex for my limited knowledge of logic programming.
Message passing between FSMs usually does instead and makes them simpler.
If you can make it work, a state machine for navigation is great, but it's often not so easy.
In web apps, this is the route, as the browser comes out of the box with a familiar and handy UI for traversing and sharing state.