DEV Community

Cover image for Building incremental views with XState Parallel States
Tim Deschryver for This is Learning

Posted on • Originally published at timdeschryver.dev

Building incremental views with XState Parallel States

Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.


Read the TLDR version on timdeschryver.dev

Let's give XState a spin to see how we can build incremental views with it to achieve a better and smoother experience for our users.

Incremental views is a technique that is used when a view needs its data from multiple sources, and where the view directly renders the data coming from these sources as they resolve. Another term for an incremental view is a progressive view.
In other words, we want to show our users the data as soon as it's retrieved, we don't wait for all of the requests to complete before we render the view. This technique can improve the user experience when some requests are sensible slower than others. The result is that our users are happy because the application feels fast and responsive.

To keep this example simple, we're going to fetch 3 separate todos, and then render them in a single list.
In a real-world scenario, we'd need to request different services (domains) to get the data we need to build the view.
For example, think of a calendar where events are fetched from different services.

We could build this in multiple ways, but I prefer to use parallel (or orthogonal) states.

Why use parallel states

Within a parallel state machine, there are multiple active state nodes that live independently from each other.
You can compare it to one big node that has multiple children, which are also single state machines.
This has the advantage that a child node lives on its own without bothering other state nodes.
In our example, this means that each request has its own distinct state representing the lifecycle of the request, idle, loading, success, and error.

Because all of the state nodes belong to one machine, they can all react to the same events.
In our example, there will be a single fetch event, which is the trigger to fire all of the requests.
A single state node can also have node-specific events, for example, the success event of a request to assign the response to the context of a machine.

A parallel state machine shares a single context with the different state nodes.
This makes it easy to build a "view-model" that uses the context from the state machine.
If you would use different machines, you would have to stitch the states of multiple machines manually together.

In practice, this gives us the following state graph.

The whole state machine represented in a graph generated by the XState inspector

In the image above, we can clearly see the different state nodes, todoOne, todoTwo, and todoThree.
Each of these state nodes has its own state, we can see that todoOne and todoTwo are in the success state, while todoThree is still in the loading state. All of the events are also on the graph and how they transition to another state (going from one state to another).

How to create a parallel state machine

A small detour to machine models

Before we take a look at the machine that I came up with, let's first take a look at the machine's model API. In the next steps, the machine is created based on this model, and the defined events on the model are used by the component to communicate with the machine.

Using a model gives us a better developer experience because we can easily see the structure of the context, and all the possible events that can be sent to the machine.
Besides these benefits, a model also provides better type support while sending or reacting to events.
According to the docs, more goodies will follow!

If you have taken a look at the state graph above and compare it with the model below, you'll recognize the model within a single eye blink.

import { createModel } from 'xstate/lib/model';

export const appModel = createModel(
    {
        // comes from an external service
        todoOne: undefined as Todo | undefined,
        todoTwo: undefined as Todo | undefined,
        todoThree: undefined as Todo | undefined,

        // comes from the component
        onlyUncompleted: false,
    },
    {
        events: {
            // first group: events that all state nodes react to
            fetch: () => ({}),
            retry: () => ({}),
            focus: () => ({}),
            // second group: events where a single state node reacts to
            receivedTodoOne: (todo: Todo) => ({ todo }),
            receivedTodoTwo: (todo: Todo) => ({ todo }),
            receivedTodoThree: (todo: Todo) => ({ todo }),
            // third group: events that simply update the context
            toggleCompleted: () => ({}),
        },
    },
);
Enter fullscreen mode Exit fullscreen mode

As you can see, the model also contains an onlyUncompleted property, which can be toggled by clicking on a button in the component.
The property is used to filter the todo items that are completed (which we'll see later).

The events of this model can be categorized into 3 groups.

The first group containing the fetch, retry, and focusevents are used to fetch the todos. All the different parallel nodes react to these events and cause an internal transition from one state to another state, e.g. from idle to loading when the node receives the fetch event.

The second group of events are the specific events that belong to a single state tree, these events are receivedOne, receivedTwo, and receivedThree. Just like the first group, these events also cause an internal transition from one state to another state, e.g. from loading to success when a fetch action resolves.

The third and last group, are the events that don't belong to any state tree and thus won't cause a transition.
These events are only used to update the context of the machine. The toggleCompleted event belongs to this third group, e.g. it sets onlyUncompleted to true or false.

The parallel machine

With the model analyzed, we can now create the machine.
Let's go through the important parts that are applicable to this example, also notice that we're using the appModel model that we've defined before.

The first step to create a parallel machine is to configure the type property , which is set to parallel.
With this configured, the machine has multiple sub-nodes that are simultaneously active.

As we've seen in the graph, the machine includes three isolated state nodes: todoOne, todoTwo, and todoThree.
Each node is almost identical to the other node, with the exception that it invokes a different service (to fetch a todo), and that it updates the context which it owns. We also notice that all of the nodes share a single context within the machine.

export const appMachine = appModel.createMachine({
    id: 'app',
    type: 'parallel',
    context: appModel.initialContext,
    invoke: {
        src: 'checkForDocumentFocus',
    },
    states: {
        todoOne: {
            initial: 'idle',
            states: {
                idle: {
                    on: {
                        fetch: { target: 'loading' },
                        focus: { target: 'loading' },
                    },
                },
                loading: {
                    tags: ['loading'],
                    invoke: {
                        src: 'fetchOne',
                        onError: {
                            target: 'failure',
                        },
                    },
                    on: {
                        receivedTodoOne: {
                            target: 'success',
                            actions: appModel.assign({
                                todoOne: (_, event) => event.todo,
                            }),
                        },
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoOne: () => undefined,
                            }),
                        },
                    },
                },
                success: {
                    on: {
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoOne: () => undefined,
                            }),
                        },
                        focus: { target: 'loading' },
                    },
                },
                failure: {
                    on: {
                        retry: { target: 'loading' },
                    },
                },
            },
        },
        todoTwo: {
            initial: 'idle',
            states: {
                idle: {
                    on: {
                        fetch: { target: 'loading' },
                        focus: { target: 'loading' },
                    },
                },
                loading: {
                    tags: ['loading'],
                    invoke: {
                        src: 'fetchTwo',
                        onError: {
                            target: 'failure',
                        },
                    },
                    on: {
                        receivedTodoTwo: {
                            target: 'success',
                            actions: appModel.assign({
                                todoTwo: (_, event) => event.todo,
                            }),
                        },
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoTwo: () => undefined,
                            }),
                        },
                    },
                },
                success: {
                    on: {
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoTwo: () => undefined,
                            }),
                        },
                        focus: { target: 'loading' },
                    },
                },
                failure: {
                    on: {
                        retry: { target: 'loading' },
                    },
                },
            },
        },
        todoThree: {
            initial: 'idle',
            states: {
                idle: {
                    on: {
                        fetch: { target: 'loading' },
                        focus: { target: 'loading' },
                    },
                },
                loading: {
                    tags: ['loading'],
                    invoke: {
                        src: 'fetchThree',
                        onError: {
                            target: 'failure',
                        },
                    },
                    on: {
                        receivedTodoThree: {
                            target: 'success',
                            actions: appModel.assign({
                                todoThree: (_, event) => event.todo,
                            }),
                        },
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoThree: () => undefined,
                            }),
                        },
                    },
                },
                success: {
                    on: {
                        fetch: {
                            target: 'loading',
                            actions: appModel.assign({
                                todoThree: () => undefined,
                            }),
                        },
                        focus: { target: 'loading' },
                    },
                },
                failure: {
                    on: {
                        retry: { target: 'loading' },
                    },
                },
            },
        },
    },
    on: {
        toggleCompleted: {
            actions: appModel.assign({
                onlyUncompleted: (context) => !context.onlyUncompleted,
            }),
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

An in-depth look at a state node

Now that we have a better understanding of the bigger picture, let's zoom in into a single state node.
The node can be in one state at a time and represents the state of a request, which can be in the idle, loading, success, or failure state.
Depending on the state of the node, the node can transition into another state when it receives an event.
For example, the node can transition from the idle state to the loading state, but it can't transition from the idle state to the failure state. The graph shows the possible transitions between the nodes, which makes it easier to communicate about the behaviors and the intentions.

Let's take a closer look at the loading node because this one does most of the heavy lifting.
The rest of the nodes are simple transition nodes.

Via the invoke property, the loading state invokes the fetchOne service when it becomes the active state.
The service is responsible to fetch the data from an external service and returning the data it has received.
The state machine doesn't need to know about the implementation details of the service, it's solely interested in the response of the service.
While you're developing the flow of a machine, you can focus on the essentials and worry about the details later.
This keeps the state machine simple and decoupled from the consumers (which makes a machine also reusable) and frameworks.
The implementation of the service is provided by the consumer of the state machine. In a component, this will be the real service, while the service could be stubbed in a test.

I the model you also see three different receivedTodo events. These events could be replaced with the onDone transition, but I prefer to be explicit about these and add them to the model. This practice keeps all of the events typed, and also easier to test because you have full control over the events.

When the service responds with a successful response (with the receivedTodoOne event), the todo that is added to the event is assigned to the context, and the state transitions into the success state.

Last but not unimportant, is the loading tag, which is used in the component to easily show a loading indicator while loading is the active node. As an alternative you could check if one of the state nodes is in the loading state, e.g. state.matches('todoOne.loading') || state.matches('todoTwo.loading') || state.matches('todoThree.loading'). I find that using tags is just simpler and easier to extend in the future.

{
    "todoOne": {
        "initial": "idle",
        "states": {
            "idle": {
                "on": {
                    "fetch": { "target": "loading" },
                    "focus": { "target": "loading" }
                }
            },
            "loading": {
                "tags": ["loading"],
                "invoke": {
                    "src": "fetchOne",
                    "onError": {
                        "target": "failure"
                    }
                },
                "on": {
                    "receivedTodoOne": {
                        "target": "success",
                        "actions": appModel.assign({
                            "todoOne": (_, event) => event.todo
                        })
                    },
                    "fetch": {
                        "target": "loading",
                        "actions": appModel.assign({
                            "todoOne": () => undefined
                        })
                    }
                }
            },
            "success": {
                "on": {
                    "fetch": {
                        "target": "loading",
                        "actions": appModel.assign({
                            "todoOne": () => undefined
                        })
                    },
                    "focus": { "target": "loading" }
                }
            },
            "failure": {
                "on": {
                    "retry": { "target": "loading" }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Consuming the machine

The machine is now ready to be consumed by the component.

The component provides an implementation of all the services.
Besides that, the state$ subscribes to state changes and builds the view model that is used in the template.

By using the model, the events are sent to the machine.

@Component({
    template: `
        <button (click)="fetch()">Fetch</button>
        <ng-container *ngIf="state$ | async as state">
            <div *ngIf="state.loading">Loading...</div>
            <div *ngIf="!state.loading">
                <button (click)="toggleClicked()">Toggle completed</button>
            </div>
            <pre>{{ state.todos | json }}</pre>
        </ng-container>
    `,
})
export class AppComponent {
    machine = appMachine.withConfig({
        // in a real application, these services would be @Injectable services
        services: {
            fetchOne: () => {
                return this.http.get<Todo>('https://jsonplaceholder.typicode.com/todos/1').pipe(
                    delay(1000),
                    map((todo) => appModel.events.receivedTodoOne(todo)),
                );
            },
            fetchTwo: () => {
                return this.http.get<Todo>('https://jsonplaceholder.typicode.com/todos/2').pipe(
                    delay(2000),
                    map((todo) => appModel.events.receivedTodoTwo(todo)),
                );
            },
            fetchThree: () => {
                return this.http.get<Todo>('https://jsonplaceholder.typicode.com/todos/4').pipe(
                    delay(4000),
                    map((todo) => appModel.events.receivedTodoThree(todo)),
                );
            },
            checkForDocumentFocus: () => (sendBack) => {
                const listener = () => {
                    sendBack(appModel.events.focus());
                };

                window.addEventListener('focus', listener);

                return () => {
                    window.removeEventListener('focus', listener);
                };
            },
        },
    });

    service = interpret(this.machine, { devTools: true }).start();

    state$ = from(this.service).pipe(
        filter((state) => state.changed === true),
        map((state) => {
            // build a view model from the state
            const componentState = {
                todos: [state.context.todoOne, state.context.todoTwo, state.context.todoThree]
                    .filter((todo) => todo && (state.context.onlyUncompleted ? !todo.completed : true))
                    .map((todo) => ({
                        title: todo!.completed ? `${todo!.title} (completed)` : todo!.title,
                    })),
                loading: state.hasTag('loading'),
            };
            return componentState;
        }),
    );

    constructor(private http: HttpClient) {}

    fetch() {
        this.service.send(appModel.events.fetch());
    }

    toggleClicked() {
        this.service.send(appModel.events.toggleCompleted());
    }
}
Enter fullscreen mode Exit fullscreen mode

Because most of the logic lives in the state machine, we end up with a lean component that's responsible for two things:

  • the communication with the machine
  • to render the current state of the machine

Demo application

We've discussed the why and how, but we didn't see how this translates to the user interface.

For our simple example, this means that we progressively show the todo items as they are fetched, nothing too special.
There's also a loading indicator that is shown until all of the requests are complete.

In a real-world scenario, the same technique can be applied to achieve a better and smoother user experience.
For example, to load multiple events (coming from different sources) into a calendar.

After clicking on the fetch button, the todo items appear one by one

If you want to fiddle with the code, you can use the StackBlitz below πŸ‘‡.


Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.

Top comments (0)