Skip to content

onChange callback called twice for same state when using immediate transitions #243

@QuentinFchx

Description

@QuentinFchx

Hello,

First of all thank you for your library.

The context

We're trying to implement a multi-step form and we're using a FSM (robot) as the backbone of the form.
One of the features of that form is that data filled in a given step may skip the next one.

For instance :

  • User fills the first step of the form
  • The user clicks "Next", we use the data to fetch some objects
  • if there are multiple objects, we display step2 to select one of these objets
  • otherwise, if there are 1 or 0 objects, no need for step2 we go to step3 directly
    We implemented this with a mix of invokes and immediate transitions (which we'll call intermediary steps), that always end up onto a concrete step (a "final ui state").

Also since we're evolving in a web environment, we're synchronizing the current step of the form with the url.
To achieve this, we're listening to the onChange callback and whenever a new state is reached, if it is a concrete step (not an intermediary one used to fetch information) we change the url to that step's one (ie: /step1, /step2).

The issue

However, it seems that the onChange callback is called multiple times for the same concrete step and we're struggling to understand why.

import { createMachine, guard, immediate, invoke, reduce, state, transition, interpret, d } from 'https://unpkg.com/robot3';

async function loadOptions() {
    return false ? [] : [{ id: 1 }, { id: 2 }];
}

const machine = createMachine(
    {
        step1: state(
            transition(
                'next',
                'step1loading',
                guard(ctx => ctx.form.valid)
            )
        ),
        // Loading available options
        step1loading: invoke(
            loadOptions,
            transition(
                'done',
                'step1decider',
                reduce((ctx, evt) => ({ ...ctx, options: evt.data }))
            ),
            transition('error', 'error')
        ),
        // Deciding which step to go to next
        step1decider: state(
            immediate(
                'step2',
                guard(ctx => ctx.options.length > 1)
            ),
            immediate('step3')
        ),
        step2: state(),
        step3: state(),
        error: state()
    },
    context => context
);

const service = interpret(
    machine,
    serv => {
        console.log(serv.machine.current);
    },
    {
        form: {
            valid: true
        },
        options: []
    }
);

service.send('next');

The following is logged

"step1loading" <-- OK
"step2" <-- Expected (even if step1decider is not there)
"step2" <-- Unexpected

Is it normal behaviour ? If so, by any chance, would you have guidance on how we should handle this ?
Is it an issue and the first onChange call should be done with the step1decider state instead of step2 ? Or should one of the occurence not happen at all ?

The "investigation"

Some work has been done around onChange and immediate transitions here and within the transitionTo call here but we're not familiar enough with the library to point a problem there.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions