Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Controlled mode 馃憖 #10

Closed
adam-thomas-privitar opened this issue Apr 6, 2022 · 7 comments
Closed

Controlled mode 馃憖 #10

adam-thomas-privitar opened this issue Apr 6, 2022 · 7 comments

Comments

@adam-thomas-privitar
Copy link

adam-thomas-privitar commented Apr 6, 2022

I love the idea of this library. I always thought Zod and Forms feels like a match made in heaven and this library is a great demonstration of an ergonomic API that leverages Zod.

However, whilst uncontrolled mode leads to good perf (and is partly why react-hooks-form got popular), you lose a lot of stuff (I think? I'm wanting to be wrong!). Things like:

  • Having nested forms which when saved, update the values of the parent form.
  • Having form field values which update when some other action/form field changes.
  • Wizards, where form state is built up over time, and only a subset of controls are rendered at a given time.

For really complex form flows, its difficult without being in controlled mode.

But what about perf? Well...React 18 to the rescue (?). Ive started a conversation here on Formik which outlines the idea of utilizing useTransition to get the best of both worlds: jaredpalmer/formik#3532

@esamattis
Copy link
Owner

I love the idea of this library. I always thought Zod and Forms feels like a match made in heaven and this library is a great demonstration of an ergonomic API that leverages Zod.

Thanks! 馃槉

The new useTransition hook in React 18 is very interesting. I need take a closer look into it at some point.

I think some of your use cases can be solved with useValue().

BUT!

Not nothing actually prevents you from using controlled inputs all the way with React Zorm.

It does not care. It only requires that the <form> has <input> (or textarea, option etc.) elements with name and value attributes and it goes from there. Just use useState() or your favourite state management lib to make them controlled.

The "馃洃 No controlled inputs" bullet point it the readme might be a bit miss leading 馃 It just means you are not forced to use controlled inputs but definitely can if you want to.

@adam-thomas-privitar
Copy link
Author

adam-thomas-privitar commented Apr 6, 2022

Very good point! I guess in this way -- one might imagine a stateful lib which uses react-zorm for its underlying primitives? My interest is piqued. I might have to experiment with this...

We're in a rut with Formik at the moment -- the overriding problems are performance and type safety, but we've been holding off alternatives achieving the perf via uncontrolled. I've got pictured in my head an approach which leverages useTransition and react-zorm to get the ergonomic zod-based API whilst retaining the fully controlled approach.

Perhaps another challenge that this could solve is that of server errors. Could field-level server errors (which by their nature are controlled), be reconciled with the client side zod errors?

Hmm...I'm gonna need to go away and experiment.

@esamattis
Copy link
Owner

one might imagine a stateful lib which uses react-zorm for its underlying primitives?

Certainly possible. You might want to consider setting setupListeners option to false because it gives you total control on when to run the Zod validation which is probably nice when building something on top of it.

Perhaps another challenge that this could solve is that of server errors.

Currently React Zorm has no opinions on this. So far I've just passed totally independent error object from the server and rendered it on to the form.

Could field-level server errors (which by their nature are controlled), be reconciled with the client side zod errors?

It should be possible via.superRefine() where you generate "synthetic" Zod errors based on the server response. You could do this in user land too. You just have to create the Zod Schema inside the React component since you need to dynamically add the .superRefine() based on the server response. Here's some pseudo Remix.run code:

/**
 * Form handler response type
 */
interface FormHandlerResponse {
    errors?: Record<string, string | undefined>;
}

/**
 * Generates Zod .superRefine() handlers based on the server response
 */
function refineServerError(res: FormHandlerResponse | undefined) {
    return (_value: unknown, ctx: z.RefinementCtx) => {
        // We do not care about the value since value was validated on the
        // server already. We just need to add the error here.

        if (!res) {
            // Not submitted yet
            return;
        }

        // Match zod path to server errors. This supports only one level errors atm.
        const errorMessage = res.errors?.[ctx.path[0]];

        if (errorMessage) {
            ctx.addIssue({
                code: z.ZodIssueCode.custom,
                message: "Server error: " + errorMessage,
            });
        }
    };
}

export default function MyForm() {
    // useActionData() is Remix.run hook for accessing form handler responses.
    const formResponse = useActionData<FormHandlerResponse>();

    const schema = useMemo(() => {
        const refine = refineServerError(formResponse);
        return z.object({
            username: z.string().superRefine(refine),
            email: z.string().superRefine(refine),
        });
    }, [formResponse]);

    const zo = useZorm("contact", schema);
    
    // ...
}

This would be bit cleaner if Zod would allow adding issues to nested field from the top-level object like this:

FormSchema.superRefine((value, ctx) =>{
    ctx.addIssueToPath(["username"], {
            code: z.ZodIssueCode.custom,
            message: "Server error: " + errorMessage,
    });
});

So we could get away with single .superRefine() call and be able to reuse schemas defined on the top level.

I'd might be interested adding support for this directly in React Zorm if it becomes possible. I imagine it could look something like this:

const zo = useZorm("contact", FormSchema, {
    refineErrors(addError) {
        if (response.errors.username) {
            addError(["username"], {
                code: z.ZodIssueCode.custom,
                message: response.errors.username,
            });
        }
    },
});

@esamattis
Copy link
Owner

esamattis commented Apr 7, 2022

Hmm, actually I think react-zorm could have another source (other than zod) for errors which could be used for server-side errors which would be read by the error chain.

Something like:

const zo = useZorm("contact", FormSchema, {
    serverErrors: [
        {
            path: ["user", "userName"],
            message: "Username is already in use",
        }
    ],
});

where you would of course define the serverErrors array on the server

@adam-thomas-privitar
Copy link
Author

adam-thomas-privitar commented Apr 9, 2022

@esamattis Thanks for your detailed responses, I really appreciate the time spent thinking about this. I didn't know about superRefine, so that's very helpful for me. Zod is a gift that keeps on giving.

I think react-zorm could have another source (other than zod) for errors which could be used for server-side errors which would be read by the error chain.

Yeh, we have built something similar on top of Formik, and could probably do it here as well. I think the challenge is though is that its better if they exist in state owned by the form lib, since you sometimes want behaviour such as "typing in the field that has a server error clears it". That implies that the state of those errors ought to be owned by the lib. In our Formik solution that's possible because you can setErrors. But I can also see how this very quickly leads into this lib being more stateful and therefore more complex.

Perhaps one generic approach might be to be able to register some callback which the dev can define. The return object of that would be an enhanced/merged error object.

const zo = useZorm("contact", FormSchema, {
    getError(zodError, path) {
       // ...merge logic with server side errror for given path
   }
});

Although TBH I think your recipe that leans on Zod is very valid and perhaps in keeping with the purposeful coupling to Zod -- I cant see a problem with it other than that the schema will probably need to be brought into the component scope, which hurts a little bit the composability of those schemas outside a react context. Minor niggle though!

@esamattis
Copy link
Owner

@adam-thomas-privitar Thanks for the ideas!

Just implemented a customIssues param for useZorm() which can basically take any additional ZodIssues that are generated outside the form Schema (ex. on the server). These are then merged with the issues coming from the Schema so they can be rendered with the same code. I Also added a createCustomIssues () helper for generating them type-safely.

The documentation is here:
https://github.com/esamattis/react-zorm#server-side-field-errors

Here's a full Remix example:
https://github.com/esamattis/react-zorm/blob/master/packages/remix-example/app/routes/server-side-validation.tsx

Async field validation with React Query
https://codesandbox.io/s/github/esamattis/react-zorm/tree/master/packages/codesandboxes/boxes/async-validation?file=/src/App.tsx

This is now available in v0.3.0.

I think the challenge is though is that its better if they exist in state owned by the form lib, since you sometimes want behaviour such as "typing in the field that has a server error clears it". That implies that the state of those errors ought to be owned by the lib.

The customIssues param is basically like component prop. So you could implement it by putting the issues to a React state and clear it on input typing event which will then automatically clear the error from the form.

But I can also see how this very quickly leads into this lib being more stateful and therefore more complex.

Yeah. I intentionally try to keep this lib fairly low-level to keep it small and easy to maintain. Less features, less bugs. But I'd like to find the "correct" building blocks so this lib could be used to build something more high level. This lib actually has only two components:

  • Parsing <form> with Zod Schemas
    • and I mean the native browser form, not anything React related
    • this lib is could be actually ported to any framework fairly easily because the form is parsed using the native browser apis instead of React apis.
  • Generating name attributes type-safely from the Zod schemas
    • Again this is nothing React related. It is literally generating only a string the Zorm form parser can use.

@esamattis
Copy link
Owner

Closing as not actionable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants