Frontend forms 

Gadget provides helpers for quickly building great front-end form experiences.

useActionForm hook 

The useActionForm hook is the main hook for building forms with Gadget. It manages:

  • retrieving initial form data
  • getting and setting form values as users interact with a form
  • submitting the form by calling an action in your app's backend
  • displaying validation errors from the backend if they occur

useActionForm supports calling actions on models as well as global actions.

Setting up a form 

To set up a form, call the useActionForm helper with the action you want to run when the form is submitted. The useActionForm hook returns two main functions for form building:

  • a register function that returns props for each form field
  • and a submit function for calling to run the action

For example, if you have a backend model called post and an action called create on post, you can make a form for calling that action:

JavaScript
1import { useActionForm } from "@gadgetinc/react";
2import { api } from "../api";
3
4const PostForm = () => {
5 const { register, submit } = useActionForm(api.post.create);
6
7 return (
8 <form onSubmit={submit}>
9 <label htmlFor="title">Title</label>
10 <input id="title" type="text" {...register("post.title")} />
11
12 <label htmlFor="content">Content</label>
13 <textarea id="content" {...register("post.content")} />
14 <input type="submit" />
15 </form>
16 );
17};

This example form sets up two fields, one for the title field and one for the content field of the form.

Retrieving initial form data 

When the form is first rendered, useActionForm's initial data can be:

  • empty, like for create calls
  • fetched from the backend using the findBy option
  • seeded from an existing object or GadgetRecord object using the defaultValues option

Empty initial data 

For forms that don't need any base data to start with, you can just call useActionForm with no option. Each form field will be initialized to an empty value.

For example, we can create a form for creating a new post that starts with empty data:

JavaScript
// don't pass any options, and the form will start with empty data
const { register, submit } = useActionForm(api.post.create);

Fetching initial data from the backend 

For forms that operate on existing records, useActionForm can fetch the record from the backend for you to seed the initial data for editing.

For example, we can create a form for editing an existing post by passing the findBy option to useActionForm:

JavaScript
// pass the findBy option to fetch a post from the backend, and seed the initial form data with it
const { register, submit } = useActionForm(api.post.update, {
findBy: props.postId,
});

The findBy option can also fetch records by other fields that have a findBy<Field> function defined on your API client, which comes from that field having a Uniquness field validation. This is useful for models with fields like a slug or another identifier that isn't an id. To find by a findBy<Field> field, pass an object with the field name as the key and the value to find by as the value:

JavaScript
const { register, submit } = useActionForm(api.post.update, {
// find the initial post record by slug using api.post.findBySlug, and see the initial form values with that data
findBy: { slug: props.slug },
});

Passing explicit initial data 

If you already have data in context for your form, or want to control exactly what data shows up in the initial values for each field, you can pass this data using the defaultValues option.

JavaScript
// pass the defaultValues option to use hardcoded data as the initial data
const { register, submit } = useActionForm(api.finishTask, {
defaultValues: { id: props.user.id },
});

If you're calling an action on a model, and you have a GadgetRecord object returned by a record finder, you can pass the whole record to seed the initial values using the defaultValues option:

TypeScript
1const PostForm = (props: { post: GadgetRecord }) => {
2 // pass the record option to use a GadgetRecord object as the initial data
3 const { register, submit } = useActionForm(api.post.update, {
4 defaultValues: props.post,
5 });
6
7 // ...
8};

If you have values you want all users to start with in your form, you can set them into the defaultValues to seed the form with them:

JavaScript
1const { register, submit } = useActionForm(api.post.create, {
2 defaultValues: {
3 published: false,
4 author: { _link: currentUser.id },
5 categories: ["random thoughts"],
6 },
7});

Registering fields 

The register function returned by useActionForm produces props for applying to each element that changes data in your form. <input/>, <textarea/>, <select/>, checkboxes, radio buttons, etc all can be registered with the register function.

Each registered input must pass the identifier of the field it should set in the submission data. For example, if you're calling the create action for a model called post, and you want to set the title field, you would pass post.title to the register function:

JavaScript
1import { useActionForm } from "@gadgetinc/react";
2import { api } from "../api";
3
4const PostForm = () => {
5 const { register, submit } = useActionForm(api.post.create);
6
7 return (
8 // ...
9 <input id="title" type="text" {...register("post.title")} />
10 // ...
11 );
12};

You can call the register function repeatedly for different form inputs to register multiple fields in the same form:

JavaScript
1<>
2 <input {...register("post.title")} type="text" />
3 <input {...register("post.score")} type="number" />
4 <textarea {...register("post.body")} />
5 <select {...register("post.author")}>{/* ... */}</select>
6</>;

The register function comes from react-hook-form, the excellent form state management library that useActionForm uses under the hood. For more details on the register function, see the react-hook-form docs.

Registering components without ref support 

The top-level register function relies on the ref prop to correctly integrate with an input component. Base HTML elements like <input/>, <textarea/>, <select/> etc all support refs just fine, as well as many UI libraries. But, some libraries don't pass refs down through their components to the low-level HTML elements.

For ref-less components like these, you must use a Controller object to correctly register a field instead of the top-level register function.

To register a ref-less component for a field, pass the field's name and the control object from useActionForm to a Controller. Then, mount your ref-less component inside the render function of the controller, ensuring you pass the field props down to the component from the controller:

JavaScript
1import { useActionForm, Controller } from "@gadgetinc/react";
2import { SomeUILibraryInput } from "some-ui-library-without-refs";
3import { api } from "../api";
4
5const PostForm = () => {
6 const { control, submit } = useActionForm(api.post.create);
7
8 return (
9 // ...
10 <Controller
11 // you must pass the `control` object returned by `useActionForm`
12 control={control}
13 // the key into the form state to get and set values from
14 name="post.title"
15 // ... other options you might pass to `register()`
16 // render the controlled/refless component
17 render={({ field }) => (
18 // pass the field props to the inner component (like `value` and `onChange`)
19 <SomeUILibraryInput {...field} />
20 )}
21 />
22 // ...
23 );
24};

The render function of the controller will pass these props down to the input:

proptypedescription
valueanysets the value of an input
onChange(event: React.ChangeEvent) => voiddetects changes the user makes
onBlur(event: React.ChangeEvent) => voidfor tracking dirty form state
namestringfor giving a unique name to the input in the DOM
refReact.MutableRef<any>for allowing imperative focusing of the element

As of writing, the following UI libraries are known to require the use of Controller because they have poor ref support or only use controlled components:

  • @shopify/polaris (for components like <TextField/>)
  • @material-ui/core (for components like <TextField/>)
  • react-select
  • antd

For more information on the Controller object, see the react-hook-form docs.

Registering @shopify/polaris components 

@shopify/polaris form components require controlled component props like value and onChange, and don't support refs well, so you must use a <Controller/> component to register it's form inputs:

JavaScript
1import { Card, Form, FormLayout, TextField } from "@shopify/polaris";
2import { useActionForm, Controller } from "@gadgetinc/react";
3import { api } from "../api";
4
5const AllowedTagForm = () => {
6 const { register, submit, control } = useActionForm(api.allowedTag.create);
7
8 return (
9 <Card>
10 <Form onSubmit={submit}>
11 <FormLayout>
12 <Controller
13 name="keyword"
14 control={control}
15 required
16 render={({ field }) => (
17 // Pass the field props down to the textField to set the value value and add onChange handlers
18 <TextField label="Keyword" type="text" autoComplete="off" {...field} />
19 )}
20 />
21 </FormLayout>
22 </Form>
23 </Card>
24 );
25};
Registering controlled components from UI libraries 

Controlled components from UI libraries often don't export a ref, or don't conform to the same style of props that a standard <input/> component might. For these components, you also need to use a Controller to register the field, instead of the top-level register function. See the above example for how to use Controller.

Showing form state 

Good UX practices dictate that our forms should give the user feedback as they make changes. If a field is in an error state, we need to show them which field, and what the error is. While the form is submitting, we should show them that it is happening, and report the outcome. All this state about the form is managed by useActionForm (and react-hook-form under the hood), and is available for you to use in your components.

To access the form's current state and errors, destructure the formState object returned from useActionForm, and use it to control your :

JavaScript
1import { useActionForm } from "@gadgetinc/react";
2import { api } from "../api";
3
4const PostForm = () => {
5 const {
6 formState: { isDirty, isValid, errors, isSubmitting, isSubmitSuccessful },
7 } = useActionForm(api.post.create);
8
9 return (
10 // ...
11 <button disabled={!isDirty || !isValid} />
12 // ...
13 );
14};

The formState object includes the following properties:

NameTypeDescription
isDirtybooleantrue if the user has modified any inputs away from the defaultValues, and false otherwise
dirtyFieldsRecord<string, boolean>A map of fields to the dirty state for each field. Each field's property on the object true if the user has modified this field away from the default and false otherwise
touchedFieldsRecord<string, boolean>A map of fields to the touched state for each field. Each field's property on the object true if the user has modified this field at all and false otherwise
defaultValuesRecord<string, any>The default values the form started out with, or has been reset to
isSubmittedbooleantrue if the form has ever been submitted, false otherwise
isSubmitSuccessfulbooleantrue if the form has completed a submission that encountered no errors, false otherwise
isSubmittingbooleantrue if the form is currently submitting to the backend, false otherwise
isLoadingbooleantrue if the form is currently loading data from the backend to populate the initial values or another input, false otherwise
submitCountnumberCount of times the form has been submitted
isValidbooleantrue if the form has no validation errors currently, false otherwise
isValidatingbooleantrue if the form is currently validating data, false otherwise
errorsRecord<string, string>A map of any validation errors currently present on each field

For more on the formState object, see the react-hook-form docs.

Submitting the form 

Once you have a form setup with inputs for users, you can call the action with the changed form data by calling the submit function returned from useActionForm:

JavaScript
1import { useActionForm } from "@gadgetinc/react";
2import { api } from "../api";
3
4const PostForm = () => {
5 const { register, submit, formState } = useActionForm(api.post.create);
6
7 return <form onSubmit={submit}>{/* ... */}</form>;
8};

When called, the submit function will assemble the right variables for your Gadget backend and call the action. If the action execution succeeds, isSubmitSuccessful will become true and submit will resolve with the result of calling the action. You can await the submission, and show feedback to the user depending on the outcome:

JavaScript
1import toaster from "some-toast-library";
2import { useActionForm } from "@gadgetinc/react";
3import { api } from "../api";
4
5const PostForm = () => {
6 const { register, submit, formState } = useActionForm(api.post.create);
7
8 return (
9 <form
10 onSubmit={async () => {
11 await submit();
12 toaster.positive("New post created.");
13 router.push("/posts/");
14 }}
15 >
16 {/* ... */}
17 </form>
18 );
19};

submit is an async function that will return the result of calling the backend Gadget action as a {data, error, fetching} triple. It's the same value as is returned by the useAction hook. If the submission encounters an error, the returned value will look like:

{
data: null,
error: Error
}

If the submission succeeded, the returned value will look like:

{
data: Result, // a GadgetRecord for model actions, the action return value for Global Actions
error: null
};

Showing errors 

The errors object returned in the formState object reflect validation errors from invalid user inputs to your form. You can add validations as you register fields, and you can explicitly mark inputs as invalid with the setError function.

For example, we can add a client-side required validation to the title element, and show a message when the title is blank:

JavaScript
1import { useActionForm } from "@gadgetinc/react";
2import { api } from "../api";
3
4const PostForm = () => {
5 const {
6 formState: { errors },
7 } = useActionForm(api.post.create);
8
9 return (
10 <>
11 <input {...register("title")} />
12 {/** Render an error message if there is an error on the title attribute */}
13 {errors.title?.type === "required" && <p role="alert">Title is required</p>}
14 </>
15 );
16};

Handling server-side validations 

Server-side validations on models are automatically handled by useActionForm. When the input is invalid, your app's GraphQL API will return structured data that describes each of the invalid fields, and useActionForm will automatically update the errors object with the right messages.

Adding client-side validations 

Client-side validations give a nice experience for your user where they get real-time feedback on their entries into a form, without needing to wait until they submit the form to find out if what they've entered is valid.

useActionForm supports client-side validations via react-hook-form's base validation support, as well as its schema validator support.

For basic validations, you can pass validation information when you register a field (with either register or a Controller/>).

JavaScript
<input {...register("title", { required: true })} />;

Then, the formState.errors object will contain an error on the title attribute if the field is empty.

The available base validation rules are:

  • required for requiring some input into a field
  • min for a minimum number
  • max for a maximum number
  • minLength for a minimum length string
  • maxLength for a maximum length string
  • pattern for validating against a regexp
  • validate for passing a custom validation function

Read more about react-hook-form's built-in base validations in the react-hook-form docs.

Setting errors explicitly 

useActionForm returns a setError function for explicitly setting errors on a field imperatively.

JavaScript
1const { setError } = useActionForm(api.post.create);
2
3useEffect(() => {
4 setError("title", {
5 type: "manual",
6 message: "This is an error on the title field",
7 });
8}, [setError]);

Read more about react-hook-form's setError function in the react-hook-form docs.

useActionForm also returns a clearErrors function for removing all or some errors on the form imperatively.

JavaScript
1const { clearErrors } = useActionForm(api.post.create);
2
3// ...
4<button type="button" onClick={() => clearErrors("title")}>
5 Clear Title Errors
6</button>;

Read more about react-hook-form's clearErrors function in the react-hook-form docs.

Handling transport errors 

In addition to validation errors, your application can encounter other errors when handling form submissions like the user's internet being disconnected or a 500 error during processing. These errors are often unexpected, and they don't pertain to any field in particular.

To display these errors to the user, destructure the top-level error object from calling useActionForm:

JavaScript
1import { useActionForm } from "@gadgetinc/react";
2import { api } from "../api";
3
4const PostForm = () => {
5 const { error } = useActionForm(api.post.create);
6
7 return (
8 <>
9 {error && <p class="error">There was an error: {error.message}</p>}
10 {/** ... */}
11 </>
12 );
13};

The error object will be null if no errors have been encountered, or contain any thrown Error objects. It will be populated with any transport errors fetching the initial data for the form (when using findBy), as well as any transport errors when submitting the form. The error object won't be populated if server-side validation errors are encountered.

Resetting the form 

After a form is submitted, it's often useful to reset the form to a clean state. useActionForm returns a reset function for this purpose. You can call reset to reset the form to its initial state, or pass an object to reset to reset the form to a specific set of values.

JavaScript
1const { reset } = useActionForm(api.post.create);
2
3// call reset to reset the form's value
4reset();
5
6// call reset with specific values to reset to those values
7reset({ title: "new form title", body: "new body" });

reset also accepts specific options to keep various elements of the form state in place, like keepErrors, keepDirty, etc. See the react-hook-form docs for more info on the reset function.

Resetting automatically after submitting 

useActionForm will not reset the data automatically after a successful submission. If you'd like to reset your form's data after a successful submission, you can use a useEffect to do so in your form component:

JavaScript
1const {
2 reset,
3 formState: { isSubmitSuccessful },
4} = useActionForm(api.post.create);
5
6useEffect(() => {
7 reset({
8 data: "new data",
9 });
10}, [isSubmitSuccessful]);

Form file uploads 

useActionForm does not currently support file uploads.

To handle file uploads as part of a form, we recommend checking out our storing files guide where you can learn how to build frontend upload forms. You can alternatively use other popular React libraries for building forms that support file uploads. For more information, check out other form approaches.

Other form approaches 

useActionForm is the recommended way to build forms with Gadget, but it isn't the only way.

useAction hook 

If you prefer to manage your form state on your own but still want to use your app's GraphQL API for processing the submitted data, you can use the useAction hook for calling actions. The useAction hook takes care of managing the request lifecycle, error handling, and ensuring React re-renders appropriately. It serves as an excellent method for invoking actions within your app.

For example, if you have a post model, you can create a new post by calling the act function returned by the useAction hook:

jsx
const [{ data, fetching, error }, act] = useAction(api.post.create);
// when ready, run the `act` function to trigger the action
act({ title: "Example Post", body: "some post content" });

We can use the useAction hook in a React component that calls the action with data from a form:

jsx
1import { useState } from "react";
2import { useAction } from "@gadgetinc/react";
3import { api } from "../api";
4
5export const CreatePostForm = (props) => {
6 const [title, setTitle] = useState("");
7 const [body, setBody] = useState("");
8
9 const [{ data, fetching, error }, act] = useAction(api.post.create);
10
11 if (fetching) {
12 return <div>Saving...</div>;
13 }
14
15 if (error) {
16 return <div>Error: {error.message}</div>;
17 }
18
19 return (
20 <form
21 onSubmit={() => {
22 // run the action function when the form is submitted
23 // the component will re-render with `fetching: true` initially, and then when the response arrives, render again with the result in `data`.
24 void act({ title, body });
25 }}
26 >
27 <label>Title</label>
28 <input value={title} onChange={(e) => setTitle(e.target.value)} />
29 <label>Body</label>
30 <textarea onChange={(e) => setTitle(e.target.value)}>{body}</textarea>
31 <input type="submit" />
32 </form>
33 );
34};

No-input forms 

Certain forms do not incorporate input elements; instead, they trigger backend logic when users click on them. For instance, a button that triggers a data sync may not require any user input but still needs to trigger an action. In cases where there is no need to manage form state, opting for useAction over useActionForm is often a more straightforward approach to handle these elements.

For example, if we have a post model that has a publish action, we can build a button to trigger this mutation with the useAction hook:

jsx
1import { useAction } from "@gadgetinc/react";
2import { api } from "../api";
3
4export const PostPublishButton = (props) => {
5 const [{ data, fetching, error }, act] = useAction(api.post.publish);
6
7 return (
8 <button
9 onClick={async () => {
10 // run the action function when the button is clicked
11 try {
12 await act({ id: props.post.id });
13 } catch (error) {
14 // show the user any errors
15 alert(error.message);
16 }
17 }}
18 disabled={fetching}
19 >
20 Publish
21 </button>
22 );
23};

The React ecosystem has several other approaches to form building which can be used with Gadget. Popular libraries like formik or plain state management with useState and useReducer in React can be used to build forms with Gadget as well.