Frontend forms
Gadget provides helpers for quickly building great frontend form experiences.
AutoForm autocomponents
Gadget will generate forms for you automatically that are pre-hooked up to call your app's API. Forms are customizable and use an underlying design system to ensure a consistent look and feel. This is the fastest way to get started with forms in Gadget.
The following will auto-generate a form for creating a new post
and will include inputs for each field on the post
model:
1import { AutoForm } from "@gadgetinc/react/auto/polaris";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return <AutoForm action={api.post.create} />;8};
1import { AutoForm } from "@gadgetinc/react/auto/polaris";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return <AutoForm action={api.post.create} />;8};
See the autocomponents guide for more info.
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:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return (8 <form onSubmit={submit}>9 <label htmlFor="title">Title</label>10 <input id="title" type="text" {...register("post.title")} />11 <label htmlFor="content">Content</label>12 <textarea id="content" {...register("post.content")} />13 <input type="submit" />14 </form>15 );16};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return (8 <form onSubmit={submit}>9 <label htmlFor="title">Title</label>10 <input id="title" type="text" {...register("post.title")} />11 <label htmlFor="content">Content</label>12 <textarea id="content" {...register("post.content")} />13 <input type="submit" />14 </form>15 );16};
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 thedefaultValues
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:
// don't pass any options, and the form will start with empty dataconst { register, submit } = useActionForm(api.post.create);
// don't pass any options, and the form will start with empty dataconst { 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
:
// pass the findBy option to fetch a post from the backend, and seed the initial form data with itconst { register, submit } = useActionForm(api.post.update, {findBy: props.postId,});
// pass the findBy option to fetch a post from the backend, and seed the initial form data with itconst { 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 Uniqueness 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:
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 datafindBy: { slug: props.slug },});
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 datafindBy: { 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.
// pass the defaultValues option to use hardcoded data as the initial dataconst { register, submit } = useActionForm(api.finishTask, {defaultValues: { id: props.user.id },});
// pass the defaultValues option to use hardcoded data as the initial dataconst { 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:
1import type { GadgetRecord } from "@gadget-client/example-app";23const PostForm = (props: { post: GadgetRecord }) => {4 // pass the record option to use a GadgetRecord object as the initial data5 const { register, submit } = useActionForm(api.post.update, {6 defaultValues: props.post,7 });89 // ...10};
1import type { GadgetRecord } from "@gadget-client/example-app";23const PostForm = (props: { post: GadgetRecord }) => {4 // pass the record option to use a GadgetRecord object as the initial data5 const { register, submit } = useActionForm(api.post.update, {6 defaultValues: props.post,7 });89 // ...10};
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:
1const { register, submit } = useActionForm(api.post.create, {2 defaultValues: {3 published: false,4 author: { _link: currentUser.id },5 categories: ["random thoughts"],6 },7});
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:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return (8 // ...9 <input id="title" type="text" {...register("post.title")} />10 );11};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return (8 // ...9 <input id="title" type="text" {...register("post.title")} />10 );11};
You can call the register
function repeatedly for different form inputs to register multiple fields in the same form:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return (8 <>9 <input {...register("post.title")} type="text" />10 <input {...register("post.score")} type="number" />11 <textarea {...register("post.body")} />12 <select {...register("post.author")}>{/* ... */}</select>13 </>14 );15};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit } = useActionForm(api.post.create);67 return (8 <>9 <input {...register("post.title")} type="text" />10 <input {...register("post.score")} type="number" />11 <textarea {...register("post.body")} />12 <select {...register("post.author")}>{/* ... */}</select>13 </>14 );15};
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 ref
s 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:
1import { useActionForm, Controller } from "@gadgetinc/react";2import { SomeUILibraryInput } from "some-ui-library-without-refs";3import { api } from "../api";45const PostForm = () => {6 const { control, submit } = useActionForm(api.post.create);78 return (9 // ...10 <Controller11 // you must pass the `control` object returned by `useActionForm`12 control={control}13 // the key into the form state to get and set values from14 name="post.title"15 // ... other options you might pass to `register()`16 // render the controlled/refless component17 render={({ field }) => (18 // pass the field props to the inner component (like `value` and `onChange`)19 <SomeUILibraryInput {...field} />20 )}21 />22 // ...23 );24};
1import { useActionForm, Controller } from "@gadgetinc/react";2import { SomeUILibraryInput } from "some-ui-library-without-refs";3import { api } from "../api";45const PostForm = () => {6 const { control, submit } = useActionForm(api.post.create);78 return (9 // ...10 <Controller11 // you must pass the `control` object returned by `useActionForm`12 control={control}13 // the key into the form state to get and set values from14 name="post.title"15 // ... other options you might pass to `register()`16 // render the controlled/refless component17 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:
prop | type | description |
---|---|---|
value | any | sets the value of an input |
onChange | (event: React.ChangeEvent) => void | detects changes the user makes |
onBlur | (event: React.ChangeEvent) => void | for tracking dirty form state |
name | string | for giving a unique name to the input in the DOM |
ref | React.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 ref
s well, so you must use a <Controller/>
component to register its form inputs:
1import { Card, Form, FormLayout, TextField } from "@shopify/polaris";2import { useActionForm, Controller } from "@gadgetinc/react";3import { api } from "../api";45const AllowedTagForm = () => {6 const { register, submit, control } = useActionForm(api.allowedTag.create);78 return (9 <Card>10 <Form onSubmit={submit}>11 <FormLayout>12 <Controller13 name="keyword"14 control={control}15 required16 render={({ field }) => {17 // strip out the ref from the field, not accepted by Polaris/functional React components18 const { ref, ...fieldProps } = field;1920 // Pass the fieldProps down to the textField to set the value value and add onChange handlers21 return <TextField label="Keyword" type="text" autoComplete="off" {...fieldProps} />;22 }}23 />24 </FormLayout>25 </Form>26 </Card>27 );28};
1import { Card, Form, FormLayout, TextField } from "@shopify/polaris";2import { useActionForm, Controller } from "@gadgetinc/react";3import { api } from "../api";45const AllowedTagForm = () => {6 const { register, submit, control } = useActionForm(api.allowedTag.create);78 return (9 <Card>10 <Form onSubmit={submit}>11 <FormLayout>12 <Controller13 name="keyword"14 control={control}15 required16 render={({ field }) => {17 // strip out the ref from the field, not accepted by Polaris/functional React components18 const { ref, ...fieldProps } = field;1920 // Pass the fieldProps down to the textField to set the value value and add onChange handlers21 return <TextField label="Keyword" type="text" autoComplete="off" {...fieldProps} />;22 }}23 />24 </FormLayout>25 </Form>26 </Card>27 );28};
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
.
Registering number fields
By default, number fields in forms are treated as strings. If you want to submit a number field as a number, you can use the valueAsNumber
option when registering your field on an input element:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 const { submit, register } = useActionForm(api.person.create);67 // use valueAsNumber to submit the age as a number8 return (9 <form onSubmit={submit}>10 <input11 type="number"12 {...register("person.age", {13 valueAsNumber: true,14 })}15 />16 </form>17 );18}
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 const { submit, register } = useActionForm(api.person.create);67 // use valueAsNumber to submit the age as a number8 return (9 <form onSubmit={submit}>10 <input11 type="number"12 {...register("person.age", {13 valueAsNumber: true,14 })}15 />16 </form>17 );18}
Registering date and datetime fields
If you have a date-only field on a model, it can be registered on an <input type="date" />
element with no special handling required:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 const { submit, register } = useActionForm(api.blog.create);67 // date-only fields can be handled like any other field8 return (9 <form onSubmit={submit}>10 <input type="date" {...register("blog.publishAt")} />11 </form>12 );13}
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 const { submit, register } = useActionForm(api.blog.create);67 // date-only fields can be handled like any other field8 return (9 <form onSubmit={submit}>10 <input type="date" {...register("blog.publishAt")} />11 </form>12 );13}
Datetime fields are trickier to handle. Using <input type="datetime-local" />
does not capture timezone information by default. You can always manually add a timezone offset to the value before submitting it, but this can be error-prone.
To handle datetime fields with timezone support, it can be easiest to use a library for the datepicker widget.
For example, you can use react-datepicker
:
1import { useActionForm, Controller } from "@gadgetinc/react";2import { api } from "../api";3import { DatePicker } from "react-datepicker";45// import css here for example purposes, you can import in your main file6import "react-datepicker/dist/react-datepicker.css";78export default function () {9 const { submit, control } = useActionForm(api.blog.create);1011 return (12 <form onSubmit={submit}>13 <Controller14 control={control}15 name="blog.publishAt"16 render={({ field }) => <DatePicker {...field} selected={field.value} showTimeSelect />}17 />18 <button type="submit">Submit</button>19 </form>20 );21}
1import { useActionForm, Controller } from "@gadgetinc/react";2import { api } from "../api";3import { DatePicker } from "react-datepicker";45// import css here for example purposes, you can import in your main file6import "react-datepicker/dist/react-datepicker.css";78export default function () {9 const { submit, control } = useActionForm(api.blog.create);1011 return (12 <form onSubmit={submit}>13 <Controller14 control={control}15 name="blog.publishAt"16 render={({ field }) => <DatePicker {...field} selected={field.value} showTimeSelect />}17 />18 <button type="submit">Submit</button>19 </form>20 );21}
or library components such as MUI's @mui/x-date-pickers
:
1import { useActionForm, Controller } from "@gadgetinc/react";2import { api } from "../api";3import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";45export default function () {6 const { submit, control } = useActionForm(api.blog.create);78 return (9 <form onSubmit={submit}>10 <Controller11 name="blog.publishAt"12 control={control}13 render={({ field: { ref, ...fieldProps } }) => <DateTimePicker {...fieldProps} inputRef={ref} />}14 />15 <button type="submit">Submit</button>16 </form>17 );18}
1import { useActionForm, Controller } from "@gadgetinc/react";2import { api } from "../api";3import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";45export default function () {6 const { submit, control } = useActionForm(api.blog.create);78 return (9 <form onSubmit={submit}>10 <Controller11 name="blog.publishAt"12 control={control}13 render={({ field: { ref, ...fieldProps } }) => <DateTimePicker {...fieldProps} inputRef={ref} />}14 />15 <button type="submit">Submit</button>16 </form>17 );18}
Uploading files with forms
You can also include file uploads as part of a form submission. There are different ways to store files in Gadget, each with benefits and trade-offs.
Uploads using the File object in forms
The simplest way to handle file uploads is to use the type="file"
attribute on an <input/>
element. When a user selects a file, the file is stored in the form state as a File
object. You can then submit the form with the file data as part of the form submission.
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 const { submit, register } = useActionForm(api.blog.create);67 // use type="file" on the input element to allow users to select and submit a file8 return (9 <form onSubmit={submit}>10 <input type="file" {...register("quiz.hero")} />11 <button type="submit" disabled={isUploading}>12 Submit13 </button>14 </form>15 );16}
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 const { submit, register } = useActionForm(api.blog.create);67 // use type="file" on the input element to allow users to select and submit a file8 return (9 <form onSubmit={submit}>10 <input type="file" {...register("quiz.hero")} />11 <button type="submit" disabled={isUploading}>12 Submit13 </button>14 </form>15 );16}
Uploads using direct upload tokens
You can use direct upload tokens to upload larger files as part of form submission. You can use the setValue
function returned by useActionForm
to set the value of a file field in the form state, after uploading a file using the generated token.
This example implements drag-and-drop file upload:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";3import { useState } from "react";45export default function () {6 const { submit, setValue } = useActionForm(api.blog.create);78 // state to manage the file upload9 const [isUploading, setIsUploading] = useState(false);10 const [fileName, setFileName] = useState(null);1112 return (13 <form onSubmit={submit}>14 <div15 onDragOver={(event) => event.preventDefault()}16 onDrop={async (event) => {17 event.preventDefault();1819 const file = event.dataTransfer.files[0];20 // send the file to cloud storage21 setIsUploading(true);2223 const { url, token } = await api.getDirectUploadToken();2425 try {26 await fetch(url, {27 method: "PUT",28 headers: {29 "Content-Type": file.type,30 },31 body: file,32 });3334 // update the form state with the token and file name for submission35 setValue("blog.hero", { directUploadToken: token, fileName: file.name });36 // update local state37 setFileName(file.name);38 } finally {39 setIsUploading(false);40 }41 }}42 >43 {isUploading ? "Uploading ..." : fileName ? fileName : "Drop a file here"}44 </div>45 <button type="submit" disabled={isUploading}>46 Submit47 </button>48 </form>49 );50}
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";3import { useState } from "react";45export default function () {6 const { submit, setValue } = useActionForm(api.blog.create);78 // state to manage the file upload9 const [isUploading, setIsUploading] = useState(false);10 const [fileName, setFileName] = useState(null);1112 return (13 <form onSubmit={submit}>14 <div15 onDragOver={(event) => event.preventDefault()}16 onDrop={async (event) => {17 event.preventDefault();1819 const file = event.dataTransfer.files[0];20 // send the file to cloud storage21 setIsUploading(true);2223 const { url, token } = await api.getDirectUploadToken();2425 try {26 await fetch(url, {27 method: "PUT",28 headers: {29 "Content-Type": file.type,30 },31 body: file,32 });3334 // update the form state with the token and file name for submission35 setValue("blog.hero", { directUploadToken: token, fileName: file.name });36 // update local state37 setFileName(file.name);38 } finally {39 setIsUploading(false);40 }41 }}42 >43 {isUploading ? "Uploading ..." : fileName ? fileName : "Drop a file here"}44 </div>45 <button type="submit" disabled={isUploading}>46 Submit47 </button>48 </form>49 );50}
Registering fields on related models
You can use the useFieldArray
hook along with useActionForm
to register fields on related models. The useFieldArray
hook comes from react-hook-form
and allows you to build dynamic forms.
For example, a quiz application has a quiz
model where each quiz
record has many questions
. You can register fields on the quiz
and question
models in the same form:
1import { useActionForm, useFieldArray } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 // useActionForm for the parent `quiz` model6 const { submit, register, control } = useActionForm(api.quiz.create);78 // register fields on the 'questions' relationship of the 'quiz' model9 const { fields, append, remove } = useFieldArray({ control, name: "quiz.questions" });1011 // use register on the quiz name field12 // map over questions fields in array and register each existing question individually13 return (14 <form onSubmit={submit}>15 <input {...register("quiz.name")} />16 <button type="submit">Submit</button>17 <ol>18 {fields.map((question, index) => {19 // render each question, registering by the index in the fieldArray20 // also allows for removing questions using the array index and `remove`21 return (22 <li key={question.id}>23 <input {...register(`quiz.questions.${index}.body`)} />24 <button type="button" onClick={() => remove(index)}>25 Remove question26 </button>27 </li>28 );29 })}30 </ol>31 <div>32 <button type="button" onClick={() => append({ body: "" })}>33 Add question34 </button>35 </div>36 </form>37 );38}
1import { useActionForm, useFieldArray } from "@gadgetinc/react";2import { api } from "../api";34export default function () {5 // useActionForm for the parent `quiz` model6 const { submit, register, control } = useActionForm(api.quiz.create);78 // register fields on the 'questions' relationship of the 'quiz' model9 const { fields, append, remove } = useFieldArray({ control, name: "quiz.questions" });1011 // use register on the quiz name field12 // map over questions fields in array and register each existing question individually13 return (14 <form onSubmit={submit}>15 <input {...register("quiz.name")} />16 <button type="submit">Submit</button>17 <ol>18 {fields.map((question, index) => {19 // render each question, registering by the index in the fieldArray20 // also allows for removing questions using the array index and `remove`21 return (22 <li key={question.id}>23 <input {...register(`quiz.questions.${index}.body`)} />24 <button type="button" onClick={() => remove(index)}>25 Remove question26 </button>27 </li>28 );29 })}30 </ol>31 <div>32 <button type="button" onClick={() => append({ body: "" })}>33 Add question34 </button>35 </div>36 </form>37 );38}
Some important things to note when using useFieldArray
:
- The
name
option passed touseFieldArray
should be the path to the related model from the parent. In this case, it'squiz.questions
. - The
fields
array returned byuseFieldArray
contains the current state of the field array. You can map over this array to render each field in the array. - The
append
function returned byuseFieldArray
allows you to add new fields to the field array. - The
remove
function returned byuseFieldArray
allows you to remove fields from the field array. - The
type="button"
attribute is important for the append and remove buttons to prevent the form from submitting when clicked. - The
field.id
value (question.id
in the above example) must be used as thekey
prop on rendered list elements, NOT theindex
. This prevents re-renders from breaking the fields.
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 :
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const {6 formState: { isDirty, isValid, errors, isSubmitting, isSubmitSuccessful },7 } = useActionForm(api.post.create);89 return (10 // ...11 <button disabled={!isDirty || !isValid} />12 );13};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const {6 formState: { isDirty, isValid, errors, isSubmitting, isSubmitSuccessful },7 } = useActionForm(api.post.create);89 return (10 // ...11 <button disabled={!isDirty || !isValid} />12 );13};
The formState
object includes the following properties:
Name | Type | Description |
---|---|---|
isDirty | boolean | true if the user has modified any inputs away from the defaultValues , and false otherwise |
dirtyFields | Record<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 |
touchedFields | Record<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 |
defaultValues | Record<string, any> | The default values the form started out with, or has been reset to |
isSubmitted | boolean | true if the form has ever been submitted, false otherwise |
isSubmitSuccessful | boolean | true if the form has completed a submission that encountered no errors, false otherwise |
isSubmitting | boolean | true if the form is currently submitting to the backend, false otherwise |
isLoading | boolean | true if the form is currently loading data from the backend to populate the initial values or another input, false otherwise |
submitCount | number | Count of times the form has been submitted |
isValid | boolean | true if the form has no validation errors currently, false otherwise |
isValidating | boolean | true if the form is currently validating data, false otherwise |
errors | Record<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
:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit, formState } = useActionForm(api.post.create);67 return <form onSubmit={submit}>{/* ... */}</form>;8};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit, formState } = useActionForm(api.post.create);67 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:
1import toaster from "some-toast-library";2import { useActionForm } from "@gadgetinc/react";3import { api } from "../api";45const PostForm = () => {6 const { register, submit, formState } = useActionForm(api.post.create);78 return (9 <form10 onSubmit={async () => {11 await submit();12 toaster.positive("New post created.");13 router.push("/posts/");14 }}15 >16 {/* ... */}17 </form>18 );19};
1import toaster from "some-toast-library";2import { useActionForm } from "@gadgetinc/react";3import { api } from "../api";45const PostForm = () => {6 const { register, submit, formState } = useActionForm(api.post.create);78 return (9 <form10 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 Actionserror: null};
Submitting the form and excluding fields
There are scenarios where you'd like to exclude some fields from form submission when calling an action. You've got two options and they depend on your use case:
Setting fields as ReadOnly
with the select
option
When using an update
action, we recommend using the select
option and setting fields as ReadOnly
to exclude them from the form submission. These fields are still fetched from the backend when using findBy
.
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit, formState } = useActionForm(api.post.update, {6 findBy: "1",7 select: {8 title: true,9 body: "ReadOnly",10 },11 });1213 return <form>{/* ... */}</form>;14};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit, formState } = useActionForm(api.post.update, {6 findBy: "1",7 select: {8 title: true,9 body: "ReadOnly",10 },11 });1213 return <form>{/* ... */}</form>;14};
Using the send
option
When using an action that doesn't utilize select
you can use the send
option to control which fields are submitted.
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit, formState } = useActionForm(api.post.create, {6 findBy: "1",7 send: ["title", "body"],8 });910 return <form>{/* ... */}</form>;11};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { register, submit, formState } = useActionForm(api.post.create, {6 findBy: "1",7 send: ["title", "body"],8 });910 return <form>{/* ... */}</form>;11};
Showing errors
The errors
object returned in the formState
object reflects 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:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const {6 formState: { errors },7 } = useActionForm(api.post.create);89 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};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const {6 formState: { errors },7 } = useActionForm(api.post.create);89 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/>
).
export const Form = () => {const { register } = useActionForm(api.post.create);return <input {...register("title", { required: true })} />;};
export const Form = () => {const { register } = useActionForm(api.post.create);return <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 fieldmin
for a minimum numbermax
for a maximum numberminLength
for a minimum length stringmaxLength
for a maximum length stringpattern
for validating against a regexpvalidate
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.
1const { setError } = useActionForm(api.post.create);23useEffect(() => {4 setError("title", {5 type: "manual",6 message: "This is an error on the title field",7 });8}, [setError]);
1const { setError } = useActionForm(api.post.create);23useEffect(() => {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.
1const { clearErrors } = useActionForm(api.post.create);23// ...4<button type="button" onClick={() => clearErrors("title")}>5 Clear Title Errors6</button>;
1const { clearErrors } = useActionForm(api.post.create);23// ...4<button type="button" onClick={() => clearErrors("title")}>5 Clear Title Errors6</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
:
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { error } = useActionForm(api.post.create);67 return (8 <>9 {error && <p class="error">There was an error: {error.message}</p>}10 {/** ... */}11 </>12 );13};
1import { useActionForm } from "@gadgetinc/react";2import { api } from "../api";34const PostForm = () => {5 const { error } = useActionForm(api.post.create);67 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.
1const { reset } = useActionForm(api.post.create);23// call reset to reset the form's value4reset();56// call reset with specific values to reset to those values7reset({ title: "new form title", body: "new body" });
1const { reset } = useActionForm(api.post.create);23// call reset to reset the form's value4reset();56// call reset with specific values to reset to those values7reset({ 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:
1const {2 reset,3 formState: { isSubmitSuccessful },4} = useActionForm(api.post.create);56useEffect(() => {7 reset({8 data: "new data",9 });10}, [isSubmitSuccessful]);
1const {2 reset,3 formState: { isSubmitSuccessful },4} = useActionForm(api.post.create);56useEffect(() => {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:
const [{ data, fetching, error }, act] = useAction(api.post.create);// when ready, run the `act` function to trigger the actionact({ title: "Example Post", body: "some post content" });
const [{ data, fetching, error }, act] = useAction(api.post.create);// when ready, run the `act` function to trigger the actionact({ 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:
1import { useState } from "react";2import { useAction } from "@gadgetinc/react";3import { api } from "../api";45export const CreatePostForm = (props) => {6 const [title, setTitle] = useState("");7 const [body, setBody] = useState("");89 const [{ data, fetching, error }, act] = useAction(api.post.create);1011 if (fetching) {12 return <div>Saving...</div>;13 }1415 if (error) {16 return <div>Error: {error.message}</div>;17 }1819 return (20 <form21 onSubmit={() => {22 // run the action function when the form is submitted23 // 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};
1import { useState } from "react";2import { useAction } from "@gadgetinc/react";3import { api } from "../api";45export const CreatePostForm = (props) => {6 const [title, setTitle] = useState("");7 const [body, setBody] = useState("");89 const [{ data, fetching, error }, act] = useAction(api.post.create);1011 if (fetching) {12 return <div>Saving...</div>;13 }1415 if (error) {16 return <div>Error: {error.message}</div>;17 }1819 return (20 <form21 onSubmit={() => {22 // run the action function when the form is submitted23 // 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:
1import { useAction } from "@gadgetinc/react";2import { api } from "../api";34export const PostPublishButton = (props: { post: { id: string } }) => {5 const [{ data, fetching, error }, act] = useAction(api.post.publish);67 return (8 <button9 onClick={async () => {10 // run the action function when the button is clicked11 try {12 await act({ id: props.post.id });13 } catch (err) {14 // show the user any errors15 alert(err.message);16 }17 }}18 disabled={fetching}19 >20 Publish21 </button>22 );23};
1import { useAction } from "@gadgetinc/react";2import { api } from "../api";34export const PostPublishButton = (props: { post: { id: string } }) => {5 const [{ data, fetching, error }, act] = useAction(api.post.publish);67 return (8 <button9 onClick={async () => {10 // run the action function when the button is clicked11 try {12 await act({ id: props.post.id });13 } catch (err) {14 // show the user any errors15 alert(err.message);16 }17 }}18 disabled={fetching}19 >20 Publish21 </button>22 );23};
Other popular libraries
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.