#Actor Mental Model - Complex Form

1 messages · Page 1 of 1 (latest)

cloud pecan
#

I am working on rewriting a highly complex form at my company that has become a main source of headaches, bugs, and frustration for both developers and users.

It is a highly dynamic form with a complex schema that requires server-side interaction for validation on blur of any field. The server response can return errors, overwrite user values, and create / remove new fields if needed. Each individual field needs to be able to display these various states (errors, notifications of bounding value, etc). It's been a while since I've used xstate, particularly xstate 5 and the actor model, so I wanted to make sure I was thinking about modeling this correctly.

We will have a top level machine that will be responsible for receiving our domain object, initializing the form and holding references to the field-level child actors, spawning a promise actor when we make server requests, and correctly passing down events to the child actors on server response.

Each individual field will be a child actor that initializes with a value, and is responsible for passing messages to the parent machine when the user changes the field and receiving messages from the parent in response to a server response. The individual field will be responsible for displaying error / notification states when it receives a new value event from its parent.

In addition, there needs to be both field level undo-redo and top-level (recursive). Will history states be the main thing to use here to achieve that?

Does this all sounds reasonable? I know it's slightly vague requirements, but just making sure this modeling makes sense before I dive in too deeply.

cloud pecan
#

The other thing I'm trying to grok is integration with react-hook-form. Right now it effectively is the state manager for our app 🙃 I do think it is useful as far as an API to register inputs directly to a value, but feel like I'm going to be duplicating a lot of the RHF functionality in my machine.

https://www.youtube.com/watch?v=Xa0H-vf2VuQ this was a decent introduction but missing a lot of the complexity I feel like I'll run into.

frigid remnant
#

I've done this in a prod app, integrated with both react-hook-form, xstate and react-router.

I don't have a repo to show, but I can describe it like this:

  • react router manages the routes (eg /profiling/financial-situation), uses a shared layout and in the nested route it renders a special Form component
  • the Form component is an abstraction over rhf, which takes a schema and dynamically creates a form with fields (fields can be conditional, hidden, register custom components, etc)
  • xstate orchestrates the steps (it's a multi-step), it only controls simple events (SUBMIT, BACK, FORWARD, DISCARD), so technically it's a stepperMachine
  • steps and other derived data is kept in context (pageTitle, currentPage, nextPage, previousPage, errors, etc)
#

We tried a few things before getting to this, integrating libraries is never easy and we think that this solution takes the best of all worlds.
(We could have given more control to xstate, or have different machines managing different parts of it)
How it works:

  • on hitting /profiling we fire a GET to receive from backend all the steps and their ids/labels (we use this information to build a navigation menu with NavLinks)
  • the stepperMachine starts, it saves steps in context and with a bit of logic it derives necessary data
  • upon hitting a link in the navigation menu, router navigates to the nested route (eg /profiling/step-1)
  • the nested route that renders the Form reads machine context and fires a GET to receive its schema from the backend.
#
  • schema will allow you to render fields
  • form will allow you to submit values for validation
  • xstate controls the submit event and can store errors in context, that the form can read and signal to each field what error to show (rhf -land)
cloud pecan
#

Appreciate the breakdown of your experience! Feels like there's a lot of flexibility in how I could design this, just trying to make sure I don't run into any footguns. I think your breakdown of schema -> form -> xstate responsibility makes sense. I mostly wanted to make sure my approach with spawning child actors for field level inputs made sense. I think I'm going to have most of the complexity live there, since that's where we have the most issues atm (eg. displaying proper errors, caching bugs). The parent level machine will pretty much just spawn the child actors and invoke promise actors to the server and then act as a message broker between the two. Was going back and forth on whether I pass in values from RHF -> parent machine on submission through handleSubmit + an event or I just emit an event and my parent machine grabs a snapshot of the child fields. Guess that's mostly small impl details but easy to get hung up with my inexperience

frigid remnant
#

Yeah I took a naive approach and made a "light xstate integration" as my experience with spawning child actors and messaging between machines is very limited.
Would love to see what you come up with if it can be reproduced in a public repo.

cloud pecan
#

I should be able to post most of the logic in a public repo—the main deliverable I’m supposed to produce is a pseudo-headless package that can be consumed agnostically by different UIs, meaning most the implementation details will be abstracted till the user passes in all the callbacks at run time

#

Trying to avoid over-engineering so trying to be self critical in the process so another set of eyes would be useful

cloud pecan
umbral bolt
# cloud pecan

Sorry finally jumping into this thread - this is a good state machine candidate hah

cloud pecan
# cloud pecan The other thing I'm trying to grok is integration with react-hook-form. Right no...

Wanted to circle back to this because I am super happy with the solution I landed on and think it's an awesome example of the flexibility of xstate and the actor model in general.

The problem:
I have a top level "page" state machine that is responsible for the lifecycle of the page (dirty states, loading, redirect, etc) and handling the primary domain object (CRUD to/from server). When the user has a specific option selected (driven by a property on the top level domain object), we create a form using RHF. That form has some basic validation and plays nicely with the abstractions we've built around RHF.

I wanted xstate to be able to handle everything outside UI form state registration/control, but I wanted to be triggering validation, responding to dirty checks, resetting the form etc.

What I ended up landing on was using the trigger method in RHF (https://react-hook-form.com/docs/useform/trigger) as a promise actor passed in at my provider component in React to control the validation.

I have this event

    [CoveragePageEventTypes.USER_PRICE_QUOTE]: [
        {
            // only validate on mortgagee billed
            target: "pricing.validating",
            cond: "paymentScheduleIsMortgageeBilled",
        },
        { target: "pricing.quoting" },
    ],
}```

and in that target state

validating: {
invoke: {
src: "validateForm",
id: "validateForm",
onDone: {
target: #${machineId}.pricing.quoting,
actions: "addMortgageeBilled",
},
onError: {
target: #${machineId}.draft,
},
},
}


then in my top level layout component in React where I setup both the form provider and the machine, I pass in the trigger method as a service

validateForm: async () => {
const isValid = await trigger(undefined, { shouldFocus: true });
if (!isValid) {
throw new Error("mortgagee billed form invalid");
}
// https://github.com/orgs/react-hook-form/discussions/3715#discussioncomment-2151458
// RHF has some horrifying interaction when passed into an immutable helper downstream
return cloneDeep(getValues());
}

Performant, flexible and extensible forms with easy-to-use validation.

#

pretty simple example here but I can think of some really powerful error handling I could achieve by throwing structured errors in side of validate form here and using that to power my onError block more granularly

#
it("transitions to pricing.quoting directly on price quote event if no mortgagee billed", () => {
    const machine = interpret(
        coveragePageMachine(tr3TxDraftQuoteResponse).withConfig({
            services: {},
        })
    ).start();

    machine.send({
        type: CoveragePageEventTypes.USER_UPDATED_PAYMENT_SCHEDULE,
        params: {
            paymentSchedule: PaymentSchedule.MONTHLY,
        },
    });

    expect(machine.state.matches("draft")).toBe(true);

    machine.send({ type: CoveragePageEventTypes.USER_PRICE_QUOTE });

    expect(machine.state.matches("pricing.quoting")).toBe(true);
});

it("transitions to pricing.quoting on price quote if mortgagee billed form is valid", async () => {
    const mortgagee: MortgageeBilledFormValues & { type: string } = {
        type: "mortgagee",
        address: {
            city: "Austin",
            street_address: "1209 Alta Vista Ave",
            state: "TX",
            zip_code: "78704",
        },
        name: "Bob Bank",
        email: "[email protected]",
    };

    const machine = interpret(
        coveragePageMachine(tr3TxDraftQuoteResponse).withConfig({
            services: {
                validateForm: async () => {
                    return mortgagee;
                },
            },
        })
    ).start();

    machine.send({
        type: CoveragePageEventTypes.USER_UPDATED_PAYMENT_SCHEDULE,
        params: {
            paymentSchedule: PaymentSchedule.MORTGAGEE_BILLED,
        },
    });

    expect(machine.state.matches("draft")).toBe(true);

    machine.send({ type: CoveragePageEventTypes.USER_PRICE_QUOTE });

    expect(machine.state.matches("pricing.validating")).toBe(true);
    await waitFor(machine, (state) => state.matches("pricing.quoting"));
    expect(machine.state.matches("pricing.quoting")).toBe(true);
});

it("transitions back to draft if mortgagee form validation fails", async () => {
    const machine = interpret(
        coveragePageMachine(tr3TxDraftQuoteResponse).withConfig({
            services: {
                validateForm: async () => {
                    throw new Error("validation failed");
                },
            },
        })
    ).start();

    machine.send({
        type: CoveragePageEventTypes.USER_UPDATED_PAYMENT_SCHEDULE,
        params: {
            paymentSchedule: PaymentSchedule.MORTGAGEE_BILLED,
        },
    });

    expect(machine.state.matches("draft")).toBe(true);

    machine.send({ type: CoveragePageEventTypes.USER_PRICE_QUOTE });

    expect(machine.state.matches("pricing.validating")).toBe(true);
    await waitFor(machine, (state) => state.matches("draft"));
    expect(machine.state.matches("draft")).toBe(true);
});
#

tests looked pretty good to me

half aurora
#

We built a pretty complex wizard flow using xstate + RHF. Can confirm that the actor model is ideal for this.

In our case, it had to serve dynamic form “partials” so that we could experiment with different sales funnels (e.g. “what if we ask for the customer’s address on Step 4 instead of Step 2”)

Actor model is useful for separating concerns…

  • journey-machine mainly worried about the order of dynamic form partials / spawning child actors
  • partial-form-machines tracked the form data / submission of form groups
  • application-machine validated the domain object once all form partials were merged
#

TIP: Whenever possible let RHF shine on the client-side and treat its validation like a black box.

You can have a machine send it initial form data and then just wait for the submit handler to return fully-validated form data.

cloud pecan
#

That's pretty cool might have to pick your brain for some more details once I get to the "wizard" part of this project. This page was the most complicated part of the form—it's dealing with rather complex server side validation where a change in one field can change other fields, and we need to be able to push those "updated" values to the various inputs and have them react in way that isn't horrible UX (eg. dont just disappear when removed, dont just change input value without some sort of notification to the user, dont appear out of nowhere). It also has other functionality like field level undo / redo. It became a pretty huge nightmare to manage with RHF alone.

RHF is incredible at what its built for—uncontrolled form state. Once you start throwing a bunch of watch in and useEffect calling form method APIs imperatively (eg. setValue), I think its uncontrolled nature actually becomes a burden.

#

but I agree with the idea of letting RHF do it's thing no reason to reinvent the form wheel

half aurora
#

Yeah, not sure what kind of flexibility you have with existing logic, but I would advocate to pair back the on-screen changes / server-side validation as much as possible.

Ex - If the input to a “Yes / No” radio triggers additional user input, consider rendering the new fields on an entirely new screen.

Dynamically showing / hiding inputs makes life difficult for any user, but especially complex for screen-readers

cloud pecan
#

I absolutely agree—it's been a big fight between me and product atm. This is actually a rewrite of the preexisting form, so I'm somewhat burdened by having to match parity. I had not seen this book though, so I really appreciate you linking it. My complaints might land better if I have an educated reference to point to over me just saying "ugh feels kinda bad" in vague terms

#

will definitely give this a read

half aurora
#

Ooh yeah, I have been in very similar situations as ☝️

If product is interested in maximizing conversion, then Adam Silver’s form “audit” process provides a solid foundation for simplifying.

But other times you get folks who only care about feature parity

#

Using network latency to argue against server side validation is often successful.

500 error on blur validation could be a nightmare

pulsar jungle
half aurora