State machines and web application development

Original author: Krasimir Tsonev
  • Transfer
The year 2018 has come, many great ways to create applications have been found, but countless armies of front-end developers are still fighting for the simplicity and flexibility of web projects. They spend month after month trying to achieve their cherished goal: to find a software architecture that is free from errors and helps them do their work quickly and efficiently. I am one of these developers. I managed to find something interesting that could give us a chance to win.


Tools like React and Redux have allowed web development to take a big step in the right direction. However, they alone are not enough to create large-scale applications. It seems that the situation in the development of client-side parts of web applications can significantly improve the use of state machines. They will be discussed in this material. By the way, perhaps you have already built several of these machines, but still do not know about it.

Introducing State Machines


A state machine is a mathematical model of computation. This is an abstract concept, according to which a machine can have various states, but, at some point in time, remain in only one of them. I suppose the most famous state machine is the Turing machine . This is a machine with an unlimited number of states, which means that it can have an infinite number of states. The Turing machine does not meet the needs of modern interface development very well, since in most cases we have a finite number of states. That is why machines with a limited number of states, or, as they are often called, finite state machines, are better for us. This is a Mily machine and a Moore machine .

The difference between the two is that the Moore automaton changes state based only on its previous state. However, we have many external factors, such as user actions and processes taking place on the network, which means that the Moore machine will not work for us either. What we are looking for is very similar to the Miles machine. This finite state machine has an initial state, after which it goes into new states, based on the input data and on its current state.

One of the easiest ways to illustrate the operation of a state machine is to consider it through an analogy with a turnstile. It has a limited set of states: it can be either closed or open. Our turnstile blocks the entrance to a certain area. Let him have a drum with three slats and a mechanism for receiving money. You can go through the closed turnstile by lowering the coin into the coin acceptor, which puts the device into the open state, and pushing the bar in order to pass through the turnstile. After having passed through the turnstile, it closes again. Here is a simple diagram that shows us these states, as well as possible input signals and transitions between states.


The initial state of the turnstile is “closed” (locked). No matter how many times we push his bar, he will remain closed. However, if we drop a coin into it, the turnstile will go into the “open” (un-locked) state. At this moment, another coin will not change anything, since the turnstile will still be in the open state. On the other hand, now the push of the turnstile bar makes sense, and we can go through it. This action, in addition, will transfer our finite state machine to the initial “closed” state.

If you need to implement the only function that controls the turnstile, we probably should focus on two arguments: this is the current state and action. If you use Redux, you may be familiar with this. This is similar to the well-known reducer functions .where we get the current state, and based on the action payload, we decide what the next state will be. A reducer is a transition in the context of a state machine. In fact, any application that has a state that we can somehow change can be called a state machine. The fact is that all this, again and again, is implemented manually.

What are the strengths of state machines?


At work, we use Redux and it suits us perfectly. However, I began to notice some things that I did not like. Just because I don’t like something doesn’t mean that it doesn’t work. It's more about the fact that all this adds complexity to the project and forces me to write more code. Somehow I started a third-party project, where I had room for experimentation, and I decided to rethink our approaches to development on React and Redux. I started taking notes about what was bothering me, and realized that the abstraction of the state machine would most likely solve some of these problems. Let's get down to business and see how to implement a state machine in JavaScript.

We will solve a simple problem. We need to receive data from the server API and display it to the user. The very first step is to understand how, when thinking about a problem, think in terms of states, not transitions. Before we move on to the state machine, I want to talk about how, at a high level, what we want to create looks like:

  • A button is displayed on the page Загрузить данные.
  • The user clicks on this button.
  • The system makes a request to the server.
  • Data is being downloaded and parsed.
  • Data is displayed on the page.
  • If an error occurs, the corresponding message is displayed and a button appears on the page again Загрузить данные, which allows the user to start the process of receiving data from the server again.


Now, analyzing the problem, we think linearly, and, in fact, try to cover all possible paths to the final result. One step leads to another, the next leads to another, and so on. In code, this can be expressed as branching operators. Let's conduct a thought experiment with a program that is built on the basis of user and system actions.

How about a situation in which the user clicks the button twice? What happens if a user clicks a button while we are waiting for a response from the server? How will the system behave if the request is successful, but the data is corrupted?
To handle such situations, we will probably need various flags that signal what is happening. The presence of flags means an increase in the number of operatorsif, and, in more complex applications, an increase in the number of conflicts.


It happens because we think in transitions. We focus on how exactly the changes in the program occur, and on the order in which they occur. If, instead, focus on the various states of the application, everything will be greatly simplified. How many conditions do we have? What is their input? We use the same example:

  • Condition idle(simple). In this state, we display the button Загрузить данныеand wait for user actions. Here are the possible actions:
  • Status fetching(receiving data). The request has been sent to the server and we are waiting for its completion. Here are the possible actions:
  • Status error(error). We display an error message and show a button Загрузить данные. This state takes one action:

Here we described about the same process, but now using the states and input data.


This simplified the logic and made it more predictable. In addition, it solved some of the problems mentioned above. Please note that when the machine is in a state fetching, we do not accept events associated with a mouse click on a button. Therefore, even if the user clicks the button, nothing will happen, since the machine is not configured to react to this action when it is in a state fetching. This approach automatically eliminates the unexpected branching of code logic.

This means that we will have to cover less code during testing. In addition, some types of testing, such as integration testing, can be automated. Think that with this approach, we would have a really clear understanding of what our application is doing, and we could create a script that traverses through predefined states and transitions and generates statements. These statements can prove that we have reached each of the possible states or have completed a specific sequence of transitions.

In fact, writing out all possible states is easier than writing out all possible transitions, since we know which states we need, or which states we have. By the way, in most cases, the states would describe the logic of the functioning of our application. But if we talk about transitions, their meaning is very often unknown at the beginning of work. Errors in programs are the results of the fact that actions are performed when the application is in a state that is not designed for these actions. In addition, even if the application is in a suitable state, the action may be performed at the wrong time. Such actions put our application in a state that we do not know about, and this disables the program or leads to the fact that it behaves incorrectly. Of course, we don’t need it. State machines are good remedies for such problems. They protect us from reaching unknown states, as we set boundaries for what and when can happen, without explicitly specifying how this can happen. The concept of a state machine goes well with a unidirectional data stream. Together, they reduce the complexity of the code and give clear answers to questions about how the system got into a particular state.

Creating a state machine using JavaScript


Enough talk - it's time to program. We will use the same example. Based on the above list, let's start with the following code:

const machine = {
  'idle': {
    click: function () { ... }
  },
  'fetching': {
    success: function () { ... },
    failure: function () { ... }
  },
  'error': {
    'retry': function () { ... }
  }
}

States are represented by objects, possible input state signals are represented by methods of objects. However, the initial state is not here. We modify the above code, bringing it to the following form:

const machine = {
  state: 'idle',
  transitions: {
    'idle': {
      click: function() { ... }
    },
    'fetching': {
      success: function() { ... },
      failure: function() { ... }
    },
    'error': {
      'retry': function() { ... }
    }
  }
}

After we have identified all the states that make sense, we are ready to send them input signals and change the state of the system. We will do this using the following two auxiliary methods:

const machine = {
  dispatch(actionName, ...payload) {
    const actions = this.transitions[this.state];
    const action = this.transitions[this.state][actionName];
    if (action) {
      action.apply(machine, ...payload);
    }
  },
  changeStateTo(newState) {
    this.state = newState;
  },
  ...
}

The function dispatchchecks if there is an action with the given name among the transitions of the current state. If so, she calls this action, passing him the data transferred to her during the call. In addition, the handler actionis called with machineas a context, so we can dispatch another action with or change the state with . Following the user path from our example, the first action that we need to dispatch is . Here's what the handler for this action looks like:this.dispatch()this.changeStateTo()

click

transitions: {
  'idle': {
    click: function () {
      this.changeStateTo('fetching');
      service.getData().then(
        data => {
          try {
            this.dispatch('success', JSON.parse(data));
          } catch (error) {
            this.dispatch('failure', error)
          }
        },
        error => this.dispatch('failure', error)
      );
    }
  },
  ...
}
machine.dispatch('click');

First we change the state of the machine to fetching. Then we execute the request to the server. Suppose we have a service with a method getDatathat returns a promise. After this promise is resolved and the data is successfully parsed, we dispatch the event succes, otherwise - failure.

While everything is going as it should. Next, we need to implement the actions successand failureand describe the state input data fetching:

transitions: {
  'idle': { ... },
  'fetching': {
    success: function (data) {
      // вывод данных
      this.changeStateTo('idle');
    },
    failure: function (error) {
      this.changeStateTo('error');
    }
  },
  ...
}

Pay attention to how we saved ourselves from having to think about the previous process. We do not care about the user's clicks on the button, or about what happens with the HTTP request. We know that the application is in a state fetching, and we expect only these two actions to appear. This is a bit like creating new application engines that work in isolation.

The last thing we have to deal with is state error. It will be very good if we create code here to implement a retry, as a result, the application will be able to recover after an error occurs.

transitions: {
  'error': {
    retry: function () {
      this.changeStateTo('idle');
      this.dispatch('click');
    }
  }
}

Here you have to copy the code that is already written in the handler click. In order to avoid this, we either need to declare the handler as a function available to both actions, or first switch to a state idle, and then dispatch the action clickourselves.

A complete example of a working state machine can be found in my CodePen project.

Managing State Machines Using the Library


The state machine template works whether we use React, Vue, or Angular. As we saw in the previous section, it is possible to implement a state machine on pure JS without much difficulty. However, if you entrust this to a specialized library, this can add more flexibility to the project. Examples of good libraries for implementing state machines include Machina.js and XState . In this article, however, we'll talk about Stent , my Redux-like library that implements the concept of state machines.

Stent is an implementation of the state machine container. This library follows some ideas of the Redux and Redux-Saga projects, but it gives, in my opinion, easier to use and less constrained by templates. It is developed using an approach based on the fact that they first write documentation for the project, and then code. Following this approach, I spent weeks only designing the API. Since I wrote the library myself, I had a chance to fix the problems that I encountered using the Redux and Flux architectures.

Creating State Machines in Stent


In most cases, an application has many functions. As a result, we cannot do just one machine. Therefore, Stent allows you to create as many machines as you need:

import { Machine } from 'stent';
const machineA = Machine.create('A', {
  state: ...,
  transitions: ...
});
const machineB = Machine.create('B', {
  state: ...,
  transitions: ...
});

Later we can access these machines using the method Machine.get:

const machineA = Machine.get('A');
const machineB = Machine.get('B');

Connecting machines to rendering logic


Rendering in my case is done using React tools, but we can use any other library. It all comes down to calling a callback in which we initiate the rendering. One of the first features of the library that I created was a function connect:

import { connect } from 'stent/lib/helpers';
Machine.create('MachineA', ...);
Machine.create('MachineB', ...);
connect()
  .with('MachineA', 'MachineB')
  .map((MachineA, MachineB) => {
    ... функцию рендеринга будем вызывать здесь
  });

We inform the system about which machines we want to work with, indicating their names. The callback that we pass to the method mapis immediately called, this is done once. After that, it is called every time the state of any of the machines changes. This is where we call the render function. In this place, we have direct access to connected machines, so we can get the current state of the machines and their methods. The library also has a method mapOnceused to work with callbacks that need to be called only once, and mapSilent- in order to skip this initial one-time callback execution.

For convenience, auxiliary functions have been exported for integration with React. This is very similar to the connect construct (mapStateToProps) Redux

import React from 'react';
import { connect } from 'stent/lib/react';
class TodoList extends React.Component {
  render() {
    const { isIdle, todos } = this.props;
    ...
  }
}
// MachineA и MachineB - это машины, определённые 
// с помощью функции Machine.create
export default connect(TodoList)
  .with('MachineA', 'MachineB')
  .map((MachineA, MachineB) => {
    isIdle: MachineA.isIdle,
    todos: MachineB.state.todos
  });

Stent performs a callback and expects to receive an object. Namely, an object that is dispatched as propsa React component.

What is state in the context of Stent?


So far, states have been simple strings. Unfortunately, in the real world, you have to store in a state something more than a regular string. That is why the Stent state is an object within which there are properties. The only property reserved is name. Everything else is application-specific data. For instance:

{ name: 'idle' }
{ name: 'fetching', todos: [] }
{ name: 'forward', speed: 120, gear: 4 }

My experience with Stent shows that if a state object gets too large, then we probably need another state machine that can handle these additional properties. Identifying the various states takes some time, but I believe this is a big step forward in writing applications that are easier to manage. This is something like an attempt to plan ahead the behavior of the system and prepare the space for future actions.

Work with the state machine


In almost the same way as in the example given at the beginning of the material, when working with Stent, we need to set the possible (final) state of the machine and describe the possible input signals:

import { Machine } from 'stent';
const machine = Machine.create('sprinter', {
  state: { name: 'idle' }, // начальное состояние
  transitions: {
    'idle': {
      'run please': function () {
        return { name: 'running' };
      }
    },
    'running': {
      'stop now': function () {
        return { name: 'idle' };
      }
    }
  }
});

There is an initial state idle, which takes action run. After the machine is in a state running, you can start an action stopthat puts the machine back in state idle.

You may recall the auxiliary functions dispatch, and changeStateTofrom the example above. Stent provides the same features, but they are hidden inside the library, as a result, we do not have to deal with them ourselves. For convenience, based on the property transitions, Stent generates the following:

  • Helper methods to check if the machine is in some condition. So, the presence of a state idleleads to the creation of a method isIdle(), and the presence of a state leads to the creation of runninga method isRunning().
  • Helper methods for dispatching events: runPlease()and stopNow().

As a result, the following constructions are available in our example:

machine.isIdle(); // логическое значение
machine.isRunning(); // логическое значение
machine.runPlease(); // запускает действие
machine.stopNow(); // запускает действие

By combining automatically generated methods with a service function connect, we can come up with a ready-made solution. The user influences the input of the machine and the actions that lead to a change in state. Due to a change in state, the mapping function transferred is called connect, and we receive a notification about the change in state. Then the data is output to the screen.

Input and action handlers


Perhaps the most important thing in this example is the action handlers. This is the place where we write most of the application logic, as it describes how the system reacts to input data and to altered states. Sometimes it seems to me that the most successful architectural decisions made during the design of Redux are the immutability and simplicity of the reducer functions . In essence, Stent action handlers are the same thing. The handler receives the current state and data associated with the action, after which it must return a new state. If the handler does not return anything ( undefined), then the state of the machine remains unchanged.

transitions: {
  'fetching': {
    'success': function (state, payload) {
      const todos = [ ...state.todos, payload ];
      return { name: 'idle', todos };
    }
  }
}

Suppose you want to download data from a remote server. To do this, we fulfill the request and put the machine in a state fetching. As soon as the data arrives from the server, an event is raised success:

machine.success({ label: '...' });

Then we return to the state idleand save some data as an array todos. There are a couple of options here for configuring action handlers. The first and simplest case is working with a simple string, which becomes a new state.

transitions: {
  'idle': {
    'run': 'running'
  }
}

This shows the transition from state { name: 'idle' }to state { name: 'running' }using an action run(). This approach is useful when synchronous transitions between states are used, while states do not have additional data. Therefore, if something else is stored in the state object, such a transition between states will destroy such additional data. In a similar way, you can pass a state object directly:

transitions: {
  'editing': {
    'delete all todos': { name: 'idle', todos: [] }
  }
}

Here you can observe the transition from state editingto idleusing an action deleteAllTodos.

We have already seen a handler function, and the last version of the action handler is a generator function. The Redux-Saga project became the ideological inspirer of this mechanism , it looks like this:

import { call } from 'stent/lib/helpers';
Machine.create('app', {
  'idle': {
    'fetch data': function * (state, payload) {
      yield { name: 'fetching' }
      try {
        const data = yield call(requestToBackend, '/api/todos/', 'POST');
        return { name: 'idle', data };
      } catch (error) {
        return { name: 'error', error };
      }
    }
  }
});

If you do not have experience with generators, the above code fragment may look a little mysterious. However, JavaScript generators are a powerful tool. With their help, you can pause the action handler, change the state several times, and process asynchronous mechanisms.

Generator Experiments


When I first met Redux-Saga , I decided that it presented an overly complicated way to support asynchronous operations. In fact, this is a very witty implementation of the command template . The main advantage of this template is that it shares the challenge of a mechanism and its actual implementation.

In other words, we tell the system what we want, but we don’t talk about how this should happen. A series of materials by Matt Hicks helped me figure this out. I recommend that you familiarize yourself with it. I brought the same ideas to Stent. We transfer control to the system, letting it know what we need, but not really doing it. Once the action is completed, we get control back.

Here's what you can transfer to the system at the moment:

  • A state object (or string) for changing the state of a machine.
  • An auxiliary function call(it takes a synchronous function, which is a function that returns a promise or other generator function). By passing such a function, we essentially tell the system: “Run this function, and if it is asynchronous, wait. As soon as the work is done, give us the result. ”
  • Helper functions wait(it takes a string representing another action). When using this service function, we pause the handler and wait for the dispatch of another action.

Here is a function that illustrates the above examples:

const fireHTTPRequest = function () {
  return new Promise((resolve, reject) => {
    // ...
  });
}
...
transitions: {
  'idle': {
    'fetch data': function * () {
      yield 'fetching'; // устанавливает состояние в { name: 'fetching' }
      yield { name: 'fetching' }; // то же самое, что и выше
      // ожидание диспетчеризации
      // действий getTheData и checkForErrors
      const [ data, isError ] = yield wait('get the data', 'check for errors');
      // ожидание разрешения промиса,
      // возвращённого fireHTTPRequest
      const result = yield call(fireHTTPRequest, '/api/data/users');
      return { name: 'finish', users: result };
    }
  }
}

This code looks like synchronous, but, in fact, it is not. Here we see how Stent performs routine operations pending permission of the promise and working with another generator.

Redux Problems and Their Solutions with Stent


▍ Eliminating excessive template code


The architecture of Redux (like Flux) is based on the actions that circulate in the system. As the application grows, as a rule, many constants and action creators appear in it. These entities usually end up in different folders; as a result, code analysis during its execution sometimes requires additional time. In addition, when adding a new feature to an application, you always have to deal with a full set of actions, which means defining a larger number of action names and action creators.

There are no action names in Stent. The library generates action creators automatically:

const machine = Machine.create('todo-app', {
  state: { name: 'idle', todos: [] },
  transitions: {
    'idle': {
      'add todo': function (state, todo) {
        ...
      }
    }
  }
});
machine.addTodo({ title: 'Fix that bug' });

There is a creator of the action machine.addTodo, defined as a machine method. This approach also solves another problem that I encountered: finding a reducer that responds to a specific action. Usually in React components you can see the names of the action creators as something like addToDo. However, in reducers we work with an action type, which is a constant. Sometimes I have to go to the code of the creator of the action because the only way I can see the exact type. There are no types at all.

▍ Eliminate unpredictable state changes


In general, Redux is great at implementing an immutable approach to managing application state. The problem is not with Redux itself, but with the fact that the developer is allowed to dispatch any action at any time. If we assume that we have an action that turns on the light, will it be normal to perform this action twice in a row? If not, then how to solve this problem using Redux? For example, we might put some code in a reducer. This code will protect the logic of the application, checking when trying to turn on the light, is it turned on earlier. It will probably be something like a conditional statement that checks the current state.

Now the question is, is this action outside the scope of the reducer's responsibility? Should a reducer be aware of such borderline cases?

What I miss in Redux is this way to stop dispatching actions based on the current state of the application without contaminating the reducer with conditional logic. And I absolutely do not want to make such a decision in the layer where the creator of the action was called. Using Stent, filtering of meaningless actions occurs automatically, since the machine does not respond to actions that are not declared in the current state. For instance:

const machine = Machine.create('app', {
  state: { name: 'idle' },
  transitions: {
    'idle': {
      'run': 'running',
      'jump': 'jumping'
    },
    'running': {
      'stop': 'idle'
    }
  }
});
// это сработает
machine.run();
// А здесь не произойдёт ничего,
// так как машина находится в состоянии
// running и в нём имеется лишь действие stop.
machine.jump();

The fact that the machine receives only specific input at a specific time protects us from strange errors and makes our application more predictable.

States as the foundation of application architecture


Redux, like Flux, makes us think in terms of transitions. The thinking model characteristic of Redux development is mainly based on actions, and on how these actions transform the state in reducers. This is not so bad, but I found that it is much more profitable, in every sense, to operate not with transitions between states, but with the states themselves. With this approach, the developer will be occupied with questions about what the state of the application can be, and how these states represent the requirements of the logic of the project.

Summary


The concept of state machines in programming, especially in the development of user interfaces, has become a real discovery for me. I began to see state machines everywhere, I had a desire wherever possible to use them. I clearly see the benefits of having strictly defined states and transitions between them in a project. In the course of work, I always want to make my applications as simple as possible, and their code - as clear as possible. I am sure that state machines are a step in the right direction. It is a simple, yet powerful concept. Its practical use is quite capable of helping to make web applications more stable and eliminate a lot of errors typical of other development approaches.

Dear readers! Do you use state machines when working on your projects?


Also popular now: