Skip to content

TBPixel/functional-finite-state-machine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Functional Finite State Machine (FFSM)

Build Status

Content

Installation

You can install this package via npm and yarn.

npm install ffsm
# or
yarn add ffsm

Examples

Using ffsm is easy. The default export is aptly called newStateMachine (but feel free to name it whatever you'd like). Simply import the constructor and define out your states as an object of key: Function pairs!

// stop-lights.js
import newStateMachine from 'ffsm';

const fsm = newStateMachine({
    green: ({ states, transitionTo }) => {
        console.log("green light!");
        return transitionTo(states.yellow);
    },
    yellow: ({ states, transitionTo }) => {
        console.log("yellow light!");
        return transitionTo(states.red);
    },
    red: ({ states }) => {
        console.log("red light!");
    },
});

fsm.transitionTo(fsm.states.green);
// "green light!"
// "yellow light!"
// "red light!"

The classic traffic light state machine demonstrates the emphasis on simplicity for ffsm. The FSM moves to it's initial state with fsm.transitionTo(fsm.states.green), and then the internal handler is called. We destructure the state machine that's passed in, retrieving it's internal reference of states and the transitionTo function.

It's worth noting that transitionTo actually assigns a result to the relative state's state property, if one was given, otherwise it assings the optional payload passed to the handler.

HTTP Request

A clear use for the state machine would be handling an HTTP Request. You might have some special logic to display a "success" or "error" based on the result of an http callback. ffsm allows you to define conditional state transitions as part of your handler.

// request-fsm.js
import newStateMachine from 'ffsm';

const fsm = newStateMachine({
    send: ({ states, transitionTo }, uri) => {
        try {
            const response = await fetch(uri);
        } catch (err) {
            return transitionTo(states.error, err);
        }

        if (response.status >= 400) {
            return transitionTo(states.fail, response);
        }

        return transitionTo(states.success, response);
    },
    fail: ({ states }, response) => {
        console.log(`request failed with status code: ${response.status}`);
        console.error(response.data);

        return response;
    },
    success: ({ states }, response) => {
        console.log('request succeeded!');
        console.log(response.data);

        return response;
    },
    error: ({ states }, error }) => {
        console.log('something went wrong unexpectedly!');
        console.error(error);

        return error;
    },
});

The above code is really easy to follow and understand. It has logical error handling, and it takes advantage of async/await while utilizing the strictly synchronous state machine. Not only that, this state machine is highly re-usable, since it makes HTTP requests for us.

To handle errors, we can simply call the state machine and check the resulting state:

// request-fsm.js
const result = fsm.transitionTo(fsm.states.send, '/hello-world');

if (!result.name === 'success') {
    // handle error
} else {
    // handle success
}

The result of the fsm is always the last executed state. This makes it easy for us to check the results, should we need to.

The returned state has a name key that matches the key of the handler. If you want to perform some additional validation checks, you can simply verify that key and do some extra handling. Though I'd recommend instead handling that logic within each state instead.

Rational

With the advent of great finite state machine (FSM) packages like Xstate and Machina JS, not to mention dozens of others, it's fair to question why I've created yet another FSM. ffsm was created because the public API's were too verbose and classical for my tastes. Don't get me wrong, xstate is a rock solid FSM, but it's API is not very pragmatic.

ffsm attempts to address that concern by providing an API which is function-first, allowing states to handle their own transitions internally. ffsm also tries to be different by keeping it's API very minimal and simple.

Limitations

Due to the design of this FSM, there are some limitations.

  • states must be synchronous.
  • Each state must handle it's own transitions.
  • ffsm has no concept of a "start" state or an "end" state, and so you must be wary of infinite loops.

API

newStateMachine

The default export is the newStateMachine function.

// api.js
import newStateMachine from 'ffsm';

const fsm = newStateMachine({
    STATE_NAME: ({ states, transitionTo }, payload) => {/* ... */},
});

As you can see, states are defined as the keys of the object, and their values are the transition functions called when moving to that state.

current

current allows you to retrieve the state that was last pushed onto the history stack.

// api.js
const state = fsm.current();

Note that if the history stack is empty, current will return undefined.

history

history returns a copy of the history stack for inspection purposes.

// api.js
const history = fsm.history();

History is displayed in chronological order, with the most recent being at the bottom. It's worth noting that all mutations are push-state, which means that transitionTo, undo and redo all push a new state onto the history stack, rather than attempting to splice the history array.

transitionTo

transitionTo accepts a state handler reference and an optional payload, then executes the handler function.

// api.js
fsm.transitionTo(fms.states.STATE_NAME, {someData: 'foo'});

transitionTo will validate that the handler reference passed in is one of the registered states within the state machine. This is what keeps the state machine finite.

transitionTo will also return the last state pushed onto the state stack after processing. This is possible because the state machine is synchronous, and fully performs it's work before returning.

// api.js
const state = fsm.transitionTo(fsm.states.STATE_NAME);
// do whatever with state ...

transitionTo is used both internally to switch between states and externally to declare the initial state. This, to me, feels very simple and clear.

undo

undo steps back one referential state, and does not execute the handler.

// api.js
fsm.undo();

This can be useful when your next state depends on work done in the previous state. It's worth noting that undo will return the most recent state just like transitionTo.

redo

redo steps forward one referential state, and does not execute the handler.

// api.js
fsm.redo();

This can be useful when you've stepped back a few states and now want to once-again step forward. Like above, rdo will return the most recent state just like transitionTo.

factoryStateMachine

factoryStateMachine allows us to create single-use state machines more easily.

// example.js
import { factoryStateMachine } from 'ffsm';

const states = {
    send: ({ states, transitionTo }, { method, url, data, headers }) => {
        const send = async () => {
            const h = {
                'content-type': 'application/json',
            };

            return await fetch(url, {
                method: method,
                body: data,
                headers: {
                    ...h,
                    headers,
                },
            });
        };

        const res = send();
        if (res.status >= 400) {
            return transitionTo(states.error, {
                request: { method, url, data, headers },
                response: res,
            });
        }

        return transitionTo(states.success, res);
    },
    error: (_, { request, response }) => {
        console.error(`${response.status} error when sending HTTP request ${request.method}: ${request.url}`, request.data);
        console.error('received response body: ', JSON.parse(response.data.body));
    },
    success: (_, res) => {
        return JSON.parse(res.data.body);
    },
};


export const requestFSM = (method, url, data, headers) => (
    factoryStateMachine(states, states.send, { method, url, data, headers })
);

// use it later..
import { requestFSM } from 'example';

const { fsm, result } = requestFSM('POST', '/my-hello-world-api', { name: 'Tony' });
// fsm is the state machine.
// result is the state that returned after the state machine executed
// in this case, we could access result.state and have the already
// parsed JSON payload to work with.

There's a lot of interesting things to unpack:

  • factoryStateMachine accepts in the states object, an initialState and an optional payload.
  • factoryStateMachine always runs the state machine from the provided initial state immediately after execution.
  • factoryStateMachine returns an object with the state machine under the fsm property and the last ran state under the result property

fireStateMachine

Much like the factory API, the ability to easily create and throw away Finite State Machines will encourage effective use of them. Where factories allowed for easier creation of re-usable state machines, "Fire and Forget" intends to encourage easier single-use state machines.

import { fireStateMachine } from 'ffsm';

const result = fireStateMachine({
    start: ({ states, transitionTo }, payload) => transitionTo(states.middle, `start-${payload}-`),
    middle: ({ states, transitionTo }, payload) => transitionTo(states.end, `middle-${payload}-`),
    end: (_, payload) => `end-${payload}`,
}, 'foo');

console.log(result.state); // "start-foo-middle-food-end-foo"

There's a two important characteristics here. First and foremost, the initial state is simply the first one that is defined. This is to encourage fireStateMachine to be a fire-and-forget API. If you want to re-use the state machine, you should instead use factoryStateMachine.

Second, the state machine does not return the machine itself, it only returns the last executed state. You cannot inspect the state machine for details about it's state history; it's all thrown away instead.

Contributing

Please see CONTRIBUTING for details.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Support Me

Hi! I'm a developer living in Vancouver. If you wanna support me, consider following me on Twitter @TBPixel, or if you're super generous buying me a coffee :).

License

The MIT License (MIT). Please see License File for more information.