2

I've been creating an API to help manage state machines in React.

It consists of three components:

  1. <StateMachine>: Receives an xstate machine as a prop, and sets up a context for deeper components to use.
  2. <StateView>: Receives two props: state & children, and renders its children only if that state is currently active.
  3. <StateControl>: Receives some arbitrary props - each being an event used to transition the machine - and converts them to transition callbacks to be passed down to its children (which is NOT an element, but an elementType as determined by PropTypes).

Here is a visual representation of what is at play:

State Machine API Visual Diagram

Using React's context API, I can flexibly switch on/off nodes in the React tree based on the state of the machine. Here's a sample code snippet demonstrating this:

const MyMachine = () => 
{
  return (
    <StateMachine machine={sampleMachine}>
      <StateView state="initializing">
        <StateControl onSuccess="success">
          {MySampleInitializer}
        </StateControl>
      </StateView>
      <StateView state="initialized">
        <p>{"App initialized"}</p>
      </StateView>
    </StateMachine>
  );

This works great! When the machine is in the "initializing" state, MySampleInitializer gets rendered. When initialization is complete, onSuccess is called which transitions the machine to "initialized". At this point, the <p> gets rendered.

Now the problem:

In most situations, each "state view" would render a different component (which gets created & mounted when the appropriate state becomes active).

However what if we wanted to apply the machine to a single component only? For example, I have a <Form> component which handles the rendering of some form elements, and should receive different props depending on the state the form is currently in.

const MyFormMachine = () => 
{
  return (
    <StateMachine machine={formMachine}>
      <StateView state="unfilled">
        <StateControl onFill="filled">
          {(props) => <MyForm {...props} disableSubmit/>}
        </StateControl>
      </StateView>
      <StateView state="filled">
        <StateControl onClear="unfilled" onSubmit="submit">
          {(props) => <MyForm {...props}/>}
        </StateControl>
      </StateView>
      <StateView state="submitting">
        <MyForm disableInput disableSubmit showSpinner/>
      </StateView>
    </StateMachine>
  );

Using my current API, rendering a <MyForm> within each <StateView> will cause <MyForm> to be re-mounted anytime a state change happens (thereby destroying any internal state associated with it). The DOM nodes themselves will also be re-mounted, which may re-trigger things like autofocus (for instance).

I was hoping there may be a way to share the same <MyForm> instance across the various "views" such that this re-mounting does not occur. Is this possible? If not, is there an alternative solution which would fit with this API?

Any help greatly appreciated.

PS: If the question title is unsuitable, please suggest a change so that this question may more accessible. Thanks

sookie
  • 2,437
  • 3
  • 29
  • 48
  • IMO your situation looks contrived. I don't see any reasonable argument for sharing in comparison with re-mounting. BTW, what do you exactly mean by "internal state" to be persisted? Form focus? Why should it stay the same for different states? – hindmost Jul 17 '19 at 11:22
  • @hindmost I have updated OP with a code snippet, which should clarify what you asked – sookie Jul 17 '19 at 13:04
  • So far, the best solution I've been able to come up with is to store `` props as 'state' and let each view manipulate these values, which then get passed to a single `` component – sookie Jul 17 '19 at 14:13
  • Can you try giving each `MyForm` the same `id`? Probably won't work though – David Callanan Jul 18 '19 at 07:20

1 Answers1

1

The problem is that the components inside your StateView instances are always constructed regardless of whether you are in the correct state or not.

So in your form example, you always have 3 Form instances, but only 1 rendered at a time. As you've stated, you can only have 1 Form instance in order to maintain state and prevent re-mounting.

When you are passing a conditional component (MyForm) into another component (StateView), you should always wrap it inside a function.

Your StateView class can then only create the MyForm instance if you're in the correct state.

You now have only 1 instance at a time (assuming only 1 state is matched at a time), but each StateView still has its own instance which isn't shared.

From what I know, you cannot avoid separate instances unless inside the same parent component.

I would change your StateView component to handle multiple state checks instead of one. This way, your instances will be reused when the state changes (again, assuming only 1 state is matched at a time).

Your StateView construction could look something like this:

<StateMachine machine={formMachine}>
  <StateView>
  {{
    "unfilled": () => (
      <StateControl onFill="filled">
        {(props) => <MyForm {...props} disableSubmit/>}
      </StateControl>
    ),
    "filled": () => (
      <StateControl onClear="unfilled" onSubmit="submit">
        {(props) => <MyForm {...props}/>}
      </StateControl>
    ),
    "submitting": () => (
      <StateControl>
        {(props) => <MyForm disableInput disableSubmit showSpinner/>}
      </StateControl>
    )
  }}
  </StateView>
</StateMachine>

Note that in order to reuse a component, the component has to be of the same type. I wrapped the 3rd MyForm in an empty StateControl so that each state constructs a StateControl, and therefore the components can be reused.

Also note that if you do have multiple states matched at a time inside a single StateView, you can give each StateControl a key property. The same key property value should not be used on two components that may be instantiated at the same time. An easier solution would be to just have separate StateView instances, where each one will only match a single state at a time.

David Callanan
  • 5,601
  • 7
  • 63
  • 105