In almost every user facing application, forms are a necessity. They are the primary way for users to interact with your application and provide data to your backend. They are also one of the most complex parts of an application to build and maintain with many cases and features to consider. Refine's form integration aims to make this process as simple as possible while providing as many real world features as possible out of the box. This guide will cover the basics of forms in Refine and how to use them.
useForm hook orchestrates Refine's useOne, useUpdate and useCreate hooks internally to provide a single interface for form handling.
While editing or cloning a record, useOne will be used to fetch the record to provide values for the form. When creating a new record, useCreate will be used for the mutation. When updating a record, useUpdate will be used for the mutation.
This means that the useForm hook will handle all of the data fetching and mutation logic for you. All you need to do is provide the form with the correct props and it will handle the rest.
The usage of the useForm hooks may slightly differ between libraries, the core functionality is provided by the @refinedev/core's useForm hook and is the same across all implementations. Refine's core has the useForm hook which is the foundation of all the other extensions and useForm implementations in the other helper libraries.
To learn more about the usage and see useForm in action, check out the reference pages for each library:
If a router integration is made, in most of the cases this enables Refine to infer the resource, action and id from the current route and provide them to useForm hook. In most of the cases, this will prevent the need of passing explicit resource, action and id props to the hooks including useForm.
import{ useForm }from"@refinedev/core"; useForm({ // These properties will be inferred from the current route resource:"posts", action:"edit", id:1, });
To learn more about the routing, check out the Routing guide and the General Concepts guide to learn more about how it benefits the development experience.
useForm also uses the router integration to redirect the user to the desired page after a successful mutation. By default, it's the list page of the resource but this can be customized by passing a redirect prop to the useForm hook. If you want to change the redirection behavior for all forms, you can use the options.redirect prop of the <Refine> component.
import{ useForm }from"@refinedev/core"; useForm({ redirect:"show",// Can also be "list", "edit" or false });
Unsaved Changes
Check the guide
Please check the guide for more information on this topic.
Globally Configurable
This value can be configured globally. Click to see the guide for more information.
Refine's useForm hooks have a built-in feature to prevent the user from losing the unsaved changes via a confirmation dialog when changing the route/leaving the page. To enable this feature, you need to use the <UnsavedChangesNotifier /> components from the router package of the library you are using and set the warnWhenUnsavedChanges prop to true.
Used for cloning an existing record. This action mode requires an id prop to be passed to the form. The record with the given id will be fetched and the values will be used as the initial values for the form fields and the mutation will be performed to create a new record.
Relationships
Check the guide
Please check the guide for more information on this topic.
Refine handles data relations with data hooks(eg: useOne, useMany, etc.). This compositional design allows you to easily display other resources' data in your components.
However, when it comes to forms, we may want to add fields that are related to other resources. For instance, you may want to add a category field to the products resource. This field will be a select input that will display the categories fetched from the categories resource. Refine offers useSelect hook to easily manage select (like a Html <select> tag, React Select, etc.) components.
You can find more information and usage examples on following useSelect documentation pages:
In the following example, we will add a category field to the products resource. This field will be a select input populated with categories using the useSelect hook.
Headless
Ant Design
Material UI
Mantine
importReactfrom"react";import{useForm}from"@refinedev/react-hook-form";import{useSelect}from"@refinedev/core";exportconstEditPage: React.FC = ()=>{const{refineCore:{onFinish,formLoading,queryResult:productQueryResult},register,handleSubmit,} = useForm<IProduct>({refineCoreProps:{resource:"products",id:1,action:"edit",},});constproduct = productQueryResult?.data?.data;const{options,queryResult:categoriesQueryResult} =
useSelect<ICategory>({resource:"categories",defaultValue:product?.category.id,});constcategories = categoriesQueryResult?.data?.data;// find category of product by id from categoriesconstcategoryOfProduct = categories?.find((category)=>Number(category.id) === Number(product?.category.id),);return(<div><div><h2>{`Edit "${product?.name}" Product`}</h2><h2>{`Category: ${categoryOfProduct?.title}`}</h2></div><formonSubmit={handleSubmit(onFinish)}><label>Name: </label><input{...register("name",{required:true})}/><br/><label>Category: </label><select{...register("category.id",{required:true,})}defaultValue={product?.category.id}>{options?.map((category)=>{return(<optionkey={category.value}value={category.value}>{category.label}</option>);})}</select><br/><br/><inputtype="submit"value="Submit"/>{formLoading && <p>Loading</p>}</form></div>);};interface ICategory {id: number;title: string;}interface IProduct {id: number;name: string;category:{id: number };}
This is the default mode and is the most common mode. In this mode, the mutation will be performed immediately and the form will be toggle the loading state until the mutation is completed.
If the mutation fails, the error will be displayed to the user with no further action such as invalidating the cache and redirection after the mutation.
In this mode, the mutation will be performed immediately and simultaneously it will be treated as if it has succeeded. The user will be shown a success notification and the existing query cache will be optimistically updated with the provided form values for the list, many and detail queries.
If not specified the opposite, it will do the redirection to the desired page. If the mutation succeeds, the query cache will be invalidated and the active queries will trigger a refetch.
If the mutation fails, the optimistic updates will be reverted and the error will be displayed to the user.
In this mode, the mutation will be delayed for the specified amount of time but simultaneously will be treated as if it has succeeded. Identical to the optimistic mode, the existing query cache will be updated accordingly and the user will be shown a notification with a countdown.
Unless it is ordered to "undo" the action by the user, the mutation will be performed after the countdown. If the mutation succeeds, the query cache will be invalidated and the active queries will trigger a refetch.
If the mutation fails, the optimistic updates will be reverted and the error will be displayed to the user.
Invalidation
Check the guide
To learn more about caching, refer to General Concepts guide
All the queries made by Refine's data hooks and their derivatives are cached for a certain amount of time. This means that if you perform a query for a resource, the result will be cached and the next time you perform the same query, the results will be returned immediately from the cache and then if the data is considered stale, the query will be refetched in the background.
When you perform a mutation, the query cache will be invalidated by default after a successful mutation. This means that if you perform a mutation that affects the data of a query, the query will be refetched in the background and the UI will be updated accordingly.
By default, useForm will invalidate the following queries after a successful mutation:
For create and clone actions; list and many queries for the resource. This means all the related queries made by useList, useSelect, useMany, useTable etc. will be invalidated.
For edit action; in addition to the queries invalidated in create and clone modes, detail query for the resource will be invalidated. This means all the related queries made by useOne, useShow etc. will be invalidated.
In some cases, you may want to change the default invalidation behavior such as to invalidate all the resource or skipping the list queries etc. To do that, you can use the invalidates prop of the useForm to determine which query sets should be invalidated after a successful mutation.
If you want to disable the invalidation completely and handle it manually, you can pass false to the invalidates prop. Then, you can use the useInvalidate hook to invalidate the queries manually based on your conditions.
In many cases, you may want to update the query cache optimistically after a mutation before the mutation is completed. This is especially comes in handy when managing the waiting experience of the user. For example, if you are updating a record, you may want to update the query cache with the new values to show the user that the record is updated immediately and then revert the changes if the mutation fails.
NOTE
Optimistic updates are only available in optimistic and undoable mutation modes.
By default, Refine's mutations will use the provided form data/values to update the existing records in the query cache. This update process includes the list, many and detail queries related to the record and the resource.
In some cases such as the data being submitted is slightly different from the data being fetched in the structural level, you may want to customize the optimistic updates. To do that, you can use the optimisticUpdateMap prop of the useForm to determine how the query cache will be updated for each query set.
optimisticUpdateMap prop also lets you disable the optimistic updates for a specific query set by passing false to the corresponding key.
useForm({ resource:"posts", id:1, mutationMode:"optimistic", optimisticUpdateMap:{ list:( previous,// Previous query data variables,// Variables used in the query id,// Record id )=>{ // update the `previous` data using the `variables` and `id`, then return it }, many:( previous,// Previous query data variables,// Variables used in the query id,// Record id )=>{ // update the `previous` data using the `variables` and `id`, then return it }, detail:( previous,// Previous query data variables,// Variables used in the query )=>{ // update the `previous` data using the `variables`, then return it }, }, });
Server Side Validation
Globally Configurable
This value can be configured globally. Click to see the guide for more information.
Server-side form validation is a technique used to validate form data on the server before processing it. Unlike client-side validation, which is performed in the user's browser using JavaScript, server-side validation occurs on the server-side code, typically in the backend of the application.
Refine supports server-side validation out-of-the-box in all useForm derivatives. To handle server-side validation, the data providers needs to be correctly set up to return the errors in form submissions with a specific format. After this, Refine's useForm will propagate the errors to the respective form fields.
import{ HttpError }from"@refinedev/core"; const error: HttpError ={ message:"An error occurred while updating the record.", statusCode:400, // the errors field is required for server-side validation. // when the errors field is set, useForm will automatically display the error messages in the form with the corresponding fields. errors:{ title:["Title is required"], content:{ key:"form.error.content", message:"Content is required.", }, tags:true, }, };
Examples below demonstrates the server-side validation and error propagation:
Refine's Core
React Hook Form
Ant Design
Mantine
Material UIReact Hook Form
Chakra UIReact Hook Form
importtype{HttpError}from"@refinedev/core";importbaseDataProviderfrom"@refinedev/simple-rest";constdataProvider = {...baseDataProvider("https://api.fake-rest.refine.dev"),create:async()=>{// For demo purposes, we're hardcoding the error response.// In a real-world application, the error of the server should match the `HttpError` interface// or should be transformed to match it.returnPromise.reject({message:"This is an error from the server",statusCode:400,errors:{name:"Name should be at least 3 characters long",material:"Material should start with a capital letter",description:"Description should be at least 10 characters long",},}as HttpError);}};exportdefaultdataProvider;
Content: import type { HttpError } from "@refinedev/core";
import baseDataProvider from "@refinedev/simple-rest";
const dataProvider = {
...baseDataProvider("https://api.fake-rest.refine.dev"),
create: async () => {
// For demo purposes, we're hardcoding the error response.
// In a real-world application, the error of the server should match the `HttpError` interface
// or should be transformed to match it.
return Promise.reject({
message: "This is an error from the server",
statusCode: 400,
errors: {
name: "Name should be at least 3 characters long",
material: "Material should start with a capital letter",
description: "Description should be at least 10 characters long",
},
} as HttpError);
}
};
export default dataProvider;
importtype{HttpError}from"@refinedev/core";importbaseDataProviderfrom"@refinedev/simple-rest";constdataProvider = {...baseDataProvider("https://api.fake-rest.refine.dev"),create:async()=>{// For demo purposes, we're hardcoding the error response.// In a real-world application, the error of the server should match the `HttpError` interface// or should be transformed to match it.returnPromise.reject({message:"This is an error from the server",statusCode:400,errors:{name:"Name should be at least 3 characters long",material:"Material should start with a capital letter",description:"Description should be at least 10 characters long",},}as HttpError);}};exportdefaultdataProvider;
Content: import type { HttpError } from "@refinedev/core";
import baseDataProvider from "@refinedev/simple-rest";
const dataProvider = {
...baseDataProvider("https://api.fake-rest.refine.dev"),
create: async () => {
// For demo purposes, we're hardcoding the error response.
// In a real-world application, the error of the server should match the `HttpError` interface
// or should be transformed to match it.
return Promise.reject({
message: "This is an error from the server",
statusCode: 400,
errors: {
name: "Name should be at least 3 characters long",
material: "Material should start with a capital letter",
description: "Description should be at least 10 characters long",
},
} as HttpError);
}
};
export default dataProvider;
importtype{HttpError}from"@refinedev/core";importbaseDataProviderfrom"@refinedev/simple-rest";constdataProvider = {...baseDataProvider("https://api.fake-rest.refine.dev"),create:async()=>{// For demo purposes, we're hardcoding the error response.// In a real-world application, the error of the server should match the `HttpError` interface// or should be transformed to match it.returnPromise.reject({message:"This is an error from the server",statusCode:400,errors:{name:"Name should be at least 3 characters long",material:"Material should start with a capital letter",description:"Description should be at least 10 characters long",},}as HttpError);}};exportdefaultdataProvider;
Content: import type { HttpError } from "@refinedev/core";
import baseDataProvider from "@refinedev/simple-rest";
const dataProvider = {
...baseDataProvider("https://api.fake-rest.refine.dev"),
create: async () => {
// For demo purposes, we're hardcoding the error response.
// In a real-world application, the error of the server should match the `HttpError` interface
// or should be transformed to match it.
return Promise.reject({
message: "This is an error from the server",
statusCode: 400,
errors: {
name: "Name should be at least 3 characters long",
material: "Material should start with a capital letter",
description: "Description should be at least 10 characters long",
},
} as HttpError);
}
};
export default dataProvider;
importtype{HttpError}from"@refinedev/core";importbaseDataProviderfrom"@refinedev/simple-rest";constdataProvider = {...baseDataProvider("https://api.fake-rest.refine.dev"),create:async()=>{// For demo purposes, we're hardcoding the error response.// In a real-world application, the error of the server should match the `HttpError` interface// or should be transformed to match it.returnPromise.reject({message:"This is an error from the server",statusCode:400,errors:{name:"Name should be at least 3 characters long",material:"Material should start with a capital letter",description:"Description should be at least 10 characters long",},}as HttpError);}};exportdefaultdataProvider;
Content: import type { HttpError } from "@refinedev/core";
import baseDataProvider from "@refinedev/simple-rest";
const dataProvider = {
...baseDataProvider("https://api.fake-rest.refine.dev"),
create: async () => {
// For demo purposes, we're hardcoding the error response.
// In a real-world application, the error of the server should match the `HttpError` interface
// or should be transformed to match it.
return Promise.reject({
message: "This is an error from the server",
statusCode: 400,
errors: {
name: "Name should be at least 3 characters long",
material: "Material should start with a capital letter",
description: "Description should be at least 10 characters long",
},
} as HttpError);
}
};
export default dataProvider;
importtype{HttpError}from"@refinedev/core";importbaseDataProviderfrom"@refinedev/simple-rest";constdataProvider = {...baseDataProvider("https://api.fake-rest.refine.dev"),create:async()=>{// For demo purposes, we're hardcoding the error response.// In a real-world application, the error of the server should match the `HttpError` interface// or should be transformed to match it.returnPromise.reject({message:"This is an error from the server",statusCode:400,errors:{name:"Name should be at least 3 characters long",material:"Material should start with a capital letter",description:"Description should be at least 10 characters long",},}as HttpError);}};exportdefaultdataProvider;
Content: import type { HttpError } from "@refinedev/core";
import baseDataProvider from "@refinedev/simple-rest";
const dataProvider = {
...baseDataProvider("https://api.fake-rest.refine.dev"),
create: async () => {
// For demo purposes, we're hardcoding the error response.
// In a real-world application, the error of the server should match the `HttpError` interface
// or should be transformed to match it.
return Promise.reject({
message: "This is an error from the server",
statusCode: 400,
errors: {
name: "Name should be at least 3 characters long",
material: "Material should start with a capital letter",
description: "Description should be at least 10 characters long",
},
} as HttpError);
}
};
export default dataProvider;
importtype{HttpError}from"@refinedev/core";importbaseDataProviderfrom"@refinedev/simple-rest";constdataProvider = {...baseDataProvider("https://api.fake-rest.refine.dev"),create:async()=>{// For demo purposes, we're hardcoding the error response.// In a real-world application, the error of the server should match the `HttpError` interface// or should be transformed to match it.returnPromise.reject({message:"This is an error from the server",statusCode:400,errors:{name:"Name should be at least 3 characters long",material:"Material should start with a capital letter",description:"Description should be at least 10 characters long",},}as HttpError);}};exportdefaultdataProvider;
Content: import type { HttpError } from "@refinedev/core";
import baseDataProvider from "@refinedev/simple-rest";
const dataProvider = {
...baseDataProvider("https://api.fake-rest.refine.dev"),
create: async () => {
// For demo purposes, we're hardcoding the error response.
// In a real-world application, the error of the server should match the `HttpError` interface
// or should be transformed to match it.
return Promise.reject({
message: "This is an error from the server",
statusCode: 400,
errors: {
name: "Name should be at least 3 characters long",
material: "Material should start with a capital letter",
description: "Description should be at least 10 characters long",
},
} as HttpError);
}
};
export default dataProvider;
When forms are submitted, it is a good practice to notify the user about the result of the submission. useForm handles this for you, when the mutation succeeds or fails it will show a notification to the user with a proper message. This behavior can be customized or disabled using the successNotification and errorNotification props.
These props accepts both a function that returns the configuration or a static configuration, this means you'll be able to use the response of the mutation to customize the notification message.
Default Notification Values
useForm({ // If not passed explicitly, these default values will be used. Default values can also be customized via i18n. successNotification:(data, values, resource)=>{ return{ description:translate("notifications.success","Successful"), message:translate( "notifications.(edit|create)Success", "Successfully (updated|created) {resource}", ), type:"success", }; }, // If not passed explicitly, these default values will be used. Default values can also be customized via i18n. errorNotification:(error, values, resource)=>{ return{ description: error.message, message:translate( "notifications.(edit|create)Error", "Error when (updating|creating) {resource} (status code: {error.statusCode})", ), type:"error", }; }, });
In many forms, it is a good practice to save the form data automatically as the user types to avoid losing the data in case of an unexpected event. This is especially useful in long forms where the user may spend a lot of time filling the form. useForm is packed with this feature out-of-the-box.
While @refinedev/core's useForm packs this feature, the auto save is not triggered automatically. In the extensions of the useForm hook in the other libraries, the auto save is handled internally and is triggered automatically.
edit.tsx
import{ useForm }from"@refinedev/core"; const{ autoSaveProps }=useForm({ autoSave:{ enabled:true,// Enables the auto save feature, defaults to false debounce:2000,// Debounce interval to trigger the auto save, defaults to 1000 invalidateOnUnmount:true,// Invalidates the queries when the form is unmounted, defaults to false }, });
Refine's core and ui integrations are shipped with an <AutoSaveIndicator /> component that can be used to show a visual indicator to the user when the auto save is triggered. The autoSaveProps value from the useForm's return value can be passed to the <AutoSaveIndicator /> to show the auto save status to the user. It will automatically show the loading, success and error states to the user.
In some cases, you might want to change the data before submitting it to the backend. For example, you might want to add a full_name field to the form data of a user resource by combining the first_name and last_name fields. While the useForm from the @refinedev/core has the natural support for this, the useForm derivatives from the other libraries of Refine has a different approach.
Each of these form implementations have a way to modify the data before submission with a slightly different approach. To learn more about how to modify the data before submission, check out the usage examples of each library:
In many cases, you may want to redirect the user to the edit page of the record after creating it. This is especially useful in cases where the user needs to fill a long form and you don't want to lose the data in case of an unexpected event.
In the example below, we'll create multiple options for the user to choose from after creating a record. The user will be able to choose between redirecting to the list page, edit page or staying in the create page in order to continue creating records.
importReactfrom"react";import{useForm}from"@refinedev/react-hook-form";importtype{HttpError,BaseKey}from"@refinedev/core";exportconstCreate: React.FC = ()=>{const{refineCore:{onFinish,formLoading,redirect},register,handleSubmit,reset,} = useForm<IProduct, HttpError, FormValues>({refineCoreProps:{redirect:false,}});constsaveAndList = (variables: FormValues)=>{onFinish(variables).then(()=>{// The default behavior is (unless changed in <Refine /> component) redirecting to the list page.// Since we've stated as `redirect: false` in the useForm hook, we need to redirect manually.redirect("list");});};constsaveAndContinue = (variables: FormValues)=>{onFinish(variables).then(({data})=>{// We'll wait for the mutation to finish and grab the id of the created product from the response.// This will only work on `pesimistic` mutation mode.redirect("edit",data.id);});};constsaveAndAddAnother = (variables: FormValues)=>{onFinish(variables).then(()=>{// We'll wait for the mutation to finish and reset the form.reset();});};return(<div><h1>Create Product</h1><formonSubmit={handleSubmit(saveAndList)}><labelhtmlFor="name">Name</label><inputname="name"placeholder="Name"{...register("name",{required:true})}/><labelhtmlFor="material">Material</label><inputname="material"placeholder="Material"{...register("material",{required:true})}/><divstyle={{display:"flex",gap:"12px"}}><buttontype="submit">Save</button><buttontype="button"onClick={handleSubmit(saveAndContinue)}>Save and Continue Editing</button><buttontype="button"onClick={handleSubmit(saveAndAddAnother)}>Save and Add Another</button></div></form></div>);};interface IProduct {id: BaseKey;name: string;material: string;}interface FormValues {name?: string;material?: string;}