If you think that's the simplest you could get, more power to you. I personally think your example is convoluted for such a simple example, but that's just me.
However, I should point out something that is not actually trivial about your code, by proposing a refactor: your FSM adequately models a car lock button interface, but not the lock itself. If we are modeling the lock, you have two invalid transitions...because it is impossible to lock an already-locked lock, and it is impossible to unlock an already-unlocked lock.
Classes actually shine quite well here.
class On {
constructor(){
console.log('turned on')
}
turnOff(){
console.log('turning off...')
return new Off();
}
}
class Off {
constructor(){
console.log('turned off')
}
turnOn(){
console.log('turning on...')
return new On();
}
}
const toggle = new On();
toggle.turnOff().turnOn().turnOff().turnOff()
// ^ cool, ^ cool, ^ cool. ^ oh shit, typescript really doesn't want to let me do this.
Classes can model your original scenario quite easily (just add a `turnOn()` method to `On` and `turnOff()` to `Off`, but redux can't model my scenario (at least not without more boilerplate and lots of guards). More importantly, the available actions are localized...I don't have to create a monolithic state transition table logic...I only have to concern myself with the possible transitions for any given state. This makes it trivial to add new states or new actions, because I will never have to worry about the combinatorial explosion that can happen in a global transition table.
Let's step back. I think your approach work really well if you model state machine for internal usage.
However, Redux is meant to be for UI work, which is a side effect. You cannot control side-effect. You cannot guarantee that user will not try to turn switch off twice.
Even in idiomatic Redux, the switch case for reducer always include `default:` which mean everything else go here.
As you said, the Redux FSM models a car lock button interface which is exactly what Redux try model.
> I personally think your example is convoluted for such a simple example, but that's just me.
It is indeed all a matter of taste in the end, but I mean it's the exact transcription of a textbook FSM. I don't think your example would get that much simpler with a stable identifier, which brings me to:
> I don't have to create a monolithic state transition table logic
It doesn't have to be monolithic. I just created a monolithic function because it's easier for this small example. It could just as easily dispatch on state (which is what you're doing here), or on transition (which is impossible with your hierarchy), or mix and match them. I can write those examples if you're curious.
Your class example is a good example of a state machine trace (e.g. one that you might often use to create an ephemeral config object), but it's not an example of an actual persistent state machine. As soon as you use a stable identifier the type safety goes out the window.
let stateMachine = new On();
// Wait a bit for the user to do something
stateMachine = statemachine.turnOff();
// Wait again for the user to do something
stateMachine = stateMachine.turnOn();
Depending on how you annotate the initial let, one of those two lines will cause TS to complain (or you have to do a manual cast somewhere which circumvents the type safety).
Indeed if you just care about a state machine trace the same type safety holds in the explicit state transition table case if you just use TS's literal types in the reducer's type annotations. You just explicitly call the reducers you need however many times and then persist the state machine at the very end.
That is your type safety doesn't come from the representation as an object, but rather that there is no stable state machine between invocations, but only an ephemeral trace of one whose intermediate states are immediately destroyed. Traces are valuable! But you could generate the exact same trace with an explicit state transition function and you would get the exact same type safety that way. I can write it out for you if you're curious.
> I will never have to worry about the combinatorial explosion that can happen in a global transition table.
Again you don't have to worry about a combinatorial explosion in a global transition table either if you dispatch on actions or states.
The fundamental difference between what you've outlined here and the explicit state transition table is that in your approach states are first-class and transitions are not (but rather methods attached to states), whereas the explicit state transition function treats both of them as first-class entities.
For places where you really only need an ephemeral state machine trace, rather than a state machine that persists between calls, you don't need first-class transitions because the transitions are all ephemeral and cannot be dynamically called at runtime.
Where you do have users able to dynamically call transitions at runtime, you end up needing to represent those transitions somehow, and you end with something approaching Redux (indeed I think an interesting exercise would be to implement On and Off where the user either presses "a" or "b" at the keyboard to turn on and off with your classes. I suspect you end up with just the Redux approach all over again).
EDIT: I want to emphasize I don't think Redux is free of faults. There are many things I really dislike about it and the React ecosystem it integrates into. But I don't think any of that can be chalked up to a poor representation of a state machine.
As do the reducers. For example the following is a valid type signature.
function onToOff(initialState: "on", transition: "toggle"): "off"
In each of the if clauses those are what the types are inferred as, exactly equivalent in type safety to the classes (and more flexible because you can dispatch on action).
I chose string for simplicity (string literals happen to be distinct types on their own, I could easily use anything else other than strings, e.g. interfaces). Heck it could just be integers and be even simpler.
However, I should point out something that is not actually trivial about your code, by proposing a refactor: your FSM adequately models a car lock button interface, but not the lock itself. If we are modeling the lock, you have two invalid transitions...because it is impossible to lock an already-locked lock, and it is impossible to unlock an already-unlocked lock.
Classes actually shine quite well here.
Classes can model your original scenario quite easily (just add a `turnOn()` method to `On` and `turnOff()` to `Off`, but redux can't model my scenario (at least not without more boilerplate and lots of guards). More importantly, the available actions are localized...I don't have to create a monolithic state transition table logic...I only have to concern myself with the possible transitions for any given state. This makes it trivial to add new states or new actions, because I will never have to worry about the combinatorial explosion that can happen in a global transition table.