matthewp / robot

🤖 A functional, immutable Finite State Machine library
https://thisrobot.life
BSD 2-Clause "Simplified" License
1.91k stars 88 forks source link

[Question] How to update context while running invoke #203

Open Hurtak opened 1 year ago

Hurtak commented 1 year ago

Updating the context with regular state(...) is easy, you just do it inside of transition with reduce, eg.:

ready: state(
  transition('togglePanel', 'ready', reduce(togglePanel)) // update the context and stay on the same `ready` state
),

Now here is my problem (or perhaps some misunderstanding) - how do I do the same thing for invoke? What if I need some other transitions to update the context while the invoke is running?

For example I have machine which loads data to display in panel, and in the machine there is state for the data and for the panel opened/closed state - and I want to be able to toggle the panel while the data is loading. Here is how I implemented it so far:

type Context = {
  loadAsyncData: () => Promise<string[]>;
  asyncData: string[];
  panelOpened: boolean;
};

const togglePanel = (ctx: Context): Context1 => ({
  ...ctx,
  panelOpened: !ctx.panelOpened,
});

const setAsyncData = (ctx: Context, { data }: { data: string[] }): Context => ({ ...ctx, asyncData: data });

const state = {
  initial: state(
    immediate('loading')
  ),
  loading: invoke(
    ({ loadAsyncData }: Context) => loadAsyncData(),
    transition('done', 'ready', reduce(setAsyncData)),
    transition('error', 'error'),

    // Here is the problem, I am using the same approach as with the first example but
    // it does not work, because invoke is run again and it fetches the data again. 
    // Is there a way to run the invoke only once (and the done/error only once) at the beginning but not when running the `togglePanel`?
    transition('togglePanel', 'loading', reduce(togglePanel)), 
  ),
  ready: state(
    transition('togglePanel', 'ready', reduce(togglePanel))
  ),
  error: state(
    transition('togglePanel', 'error', reduce(togglePanel))
  ),
};
Hurtak commented 1 year ago

For anyone that would encounter the same problem, we managed to workaround this with introduction our own version of invoke

import { invoke, Machine, MachineState, Service, Transition } from 'robot3';

const invokeFnType: {
  enter: (machine2: Machine, service: Service<Machine>, event?: unknown) => Machine;
  // fn is inherited so it should always be defined
  fn?: (ctx: Machine['context'], event?: unknown) => Promise<unknown>;
} = {
  enter(machine2: Machine, service: Service<Machine>, event?: unknown) {
    // Only invoke the function if the machine state changed
    if (machine2.current !== service.machine.current) {
      this.fn
        ?.call(service, service.context, event)
        .then((data: unknown) => service.send({ type: 'done', data }))
        .catch((error: unknown) => service.send({ type: 'error', error }));
    }
    return machine2;
  },
};

// Only invoke the async function (and the done/error transition) when transitioning into the state from other state,
// Do not invoke when transitioning from the same state (this is the only difference between invoke and this function)
export const invokeExceptForSelfTransitions = <C, T>(
  fn: (ctx: C, event?: unknown) => Promise<T>,
  ...args: Transition[]
): MachineState => ({
  ...invoke(fn, ...args),
  ...invokeFnType,
});