koordinates / xstate-tree

Build UIs with Actors using xstate and React
MIT License
82 stars 6 forks source link

Sync external data into machines #22

Open UberMouse opened 1 year ago

UberMouse commented 1 year ago

Syncing external data into xstate-tree machines is something we do a lot of at Koordinates. Most commonly Observables representing selectors from redux or watched GraphQL queries, to bring global state into the machines. But we have some areas where we need to sync data from React land back into the machine at the top of the xstate-tree hierarchy. That has been implented fairly poorly with a combination of useMemo and withContext building a new root machine anytime the useMemo dependencies change.

These are a very similar class of problems however, getting data from the outside world into the xstate machines context, with different implementations but the same mental model from inside the machine. Both ways can work with some sort of sync event sent to the machine whenever the external data changes allowing you to write a single sync event action to update context. They would need different events however as the view syncing and the observable syncing wouldn't know about each other.

Syncing from view

Handwavey thought to add a new type declaration declaring viewInputs or some such. That could then be tied into the event for syncing from the view, props on the component build by buildRootComponent, and as an argument to the slot creator functions to require passing props to a slot in the view.

From there it's a useEffect away from sending an event to the machine every time the props change to allow syncing the data into context.

import { UsesFoo } from "./SomeOtherChildMachine";

type ViewEvent<T> = { type: "xstate-tree.viewSync"; data: T };
type ViewProps = { foo: string };

type Events = ViewEvent<ViewProps>;

// specify props view generic of the slot factories
const slots = [singleSlot<ExtractViewProp<typeof UsesFoo>("UsesFoo")];

const machine = createMachine({
  id: "example1",
  schema: {
    events: {} as Events,
    viewProps: {} as ViewProps.
  },
  on: {
   "xstate-tree.viewSync": {
      actions: immerAssign((ctx, e) => {
          ctx.foo = e.data.foo;
      })
    }
  }
})

// snip

// foo prop in view passed in from above root component or machine
const view = buildView(..., ({ slots, foo }) => {
  // And then this slot was typed as also taking a foo so it gets passed here
  return <slots.UsesFoo foo={foo} />;
});

and with a root component

const Root = buildRootComponent(machine):

<Root foo="foo" />

Syncing from obervables

This seems like it would require some sort of wrapper around a group of observables to emit a new value any time any of the observables changes, including the last emit from any other observables to ease writing the sync action handler (or would that be bad because you have no way of telling if an observable had emitted a new value :thinking:).

You would then need to invoke that wrapped observable in your machine and attach a sync event action to update context when it emits.

As much as I would like automatically inject the invocation of the observable to reduce boilerplate I don't think it's advisable as it won't show up in the xstate visualizer. The reason we have been rolling back any machine config generation helpers we have at Koordinates, breaks the visualizer as the source of truth.

// Basically just combineLatest from RxJS and mapping them to an event
const wrappedObservables$ = wrapThemUp({
  foo: foo$,
  bar: bar$
});

type ObservableSyncEvent<T> = { type: "xstate-tree.sync"; data: T };

type Events = ObservableSyncEvent<ATypeExtractor<typeof wrappedObservables$>>;

const machine = createMachine({
  id: "example1",
  schema: {
   events: {} as Events,
  },
  invoke: {
    src: wrappedObservables$
  },
  on: {
   "xstate-tree.sync": {
      actions: immerAssign((ctx, e) => {
          ctx.foo = e.data.foo;
          ctx.bar = e.data.bar;
      })
    }
  }
}))
UberMouse commented 1 year ago

I would very much love to have the syncing happen automatically (by effectively going const newContext = { ...ctx, ...syncEvent } and then opting into manual control when you needed to change that behaviour or respond to the sync event in different states. But that goes against the whole "viz is source of truth" concept