Skip to main content
Refine provides powerful form handling capabilities that work with any form library. It manages data fetching, mutations, validation, redirects, and notifications automatically.

Core Form Hook

The useForm hook from @refinedev/core orchestrates all form operations:
import { useForm } from "@refinedev/core";

const { onFinish, mutation, formLoading, redirect } = useForm({
  action: "create", // or "edit", "clone"
  resource: "posts",
  redirect: "list",
  onMutationSuccess: (data) => {
    console.log("Form saved!", data);
  },
});

Quick Start

1
Choose a Form Library
2
Refine supports multiple form libraries:
3
  • @refinedev/react-hook-form - React Hook Form integration
  • @refinedev/antd - Ant Design forms
  • @refinedev/mui - Material UI forms
  • @refinedev/mantine - Mantine forms
  • Core hooks for custom implementations
  • 4
    Install Dependencies
    5
    npm install @refinedev/react-hook-form react-hook-form
    
    6
    Create a Form
    7
    import { useForm } from "@refinedev/react-hook-form";
    import { useSelect } from "@refinedev/core";
    
    export const PostCreate = () => {
      const {
        refineCore: { onFinish, formLoading },
        register,
        handleSubmit,
        formState: { errors },
      } = useForm();
    
      const { options } = useSelect({
        resource: "categories",
      });
    
      return (
        <form onSubmit={handleSubmit(onFinish)}>
          <label>Title</label>
          <input {...register("title", { required: "Title is required" })} />
          {errors.title && <span>{errors.title.message}</span>}
    
          <label>Content</label>
          <textarea {...register("content", { required: true })} />
          {errors.content && <span>Content is required</span>}
    
          <label>Category</label>
          <select {...register("category.id", { required: true })}>
            <option value="">Select...</option>
            {options?.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>
          {errors.category && <span>Category is required</span>}
    
          <label>Status</label>
          <select {...register("status")}>
            <option value="draft">Draft</option>
            <option value="published">Published</option>
          </select>
    
          <button type="submit" disabled={formLoading}>
            {formLoading ? "Saving..." : "Save"}
          </button>
        </form>
      );
    };
    
    8
    Create Edit Form
    9
    import { useForm } from "@refinedev/react-hook-form";
    
    export const PostEdit = () => {
      const {
        refineCore: { onFinish, formLoading, queryResult },
        register,
        handleSubmit,
        formState: { errors },
      } = useForm();
    
      const isLoading = queryResult?.isLoading || formLoading;
    
      if (isLoading) return <div>Loading...</div>;
    
      return (
        <form onSubmit={handleSubmit(onFinish)}>
          <label>Title</label>
          <input {...register("title", { required: true })} />
          {errors.title && <span>Required</span>}
    
          <label>Content</label>
          <textarea {...register("content")} />
    
          <button type="submit" disabled={formLoading}>
            Update
          </button>
        </form>
      );
    };
    

    Form Actions

    Create

    For creating new records:
    const { onFinish, formLoading } = useForm({
      action: "create",
      resource: "posts",
      redirect: "edit", // Redirect to edit page after creation
    });
    
    // Flow:
    // 1. User submits form
    // 2. useForm calls useCreate
    // 3. Data provider's create method is called
    // 4. On success: notification shown, queries invalidated, redirect
    

    Edit

    For updating existing records:
    const { onFinish, queryResult } = useForm({
      action: "edit",
      resource: "posts",
      id: "123", // Can be read from route params automatically
    });
    
    // Flow:
    // 1. useForm calls useOne to fetch existing data
    // 2. Form is populated with current values
    // 3. User modifies and submits
    // 4. useForm calls useUpdate
    // 5. Data provider's update method is called
    // 6. On success: notification, invalidation, redirect
    

    Clone

    For duplicating records:
    const { onFinish, queryResult } = useForm({
      action: "clone",
      resource: "posts",
      id: "123",
    });
    
    // Flow:
    // 1. useForm calls useOne to fetch source data
    // 2. Form is populated with source values
    // 3. User modifies and submits
    // 4. useForm calls useCreate (not update!)
    // 5. New record is created with modified values
    

    Form Libraries Integration

    React Hook Form

    Full-featured with great performance:
    import { useForm } from "@refinedev/react-hook-form";
    import { Controller } from "react-hook-form";
    
    const PostForm = () => {
      const {
        refineCore: { onFinish },
        register,
        control,
        handleSubmit,
        formState: { errors },
        setValue,
        watch,
      } = useForm({
        refineCoreProps: {
          resource: "posts",
          action: "create",
        },
        defaultValues: {
          title: "",
          status: "draft",
        },
      });
    
      // Watch field changes
      const status = watch("status");
    
      return (
        <form onSubmit={handleSubmit(onFinish)}>
          {/* Simple input */}
          <input {...register("title")} />
    
          {/* With validation */}
          <input
            {...register("slug", {
              required: "Slug is required",
              pattern: {
                value: /^[a-z0-9-]+$/,
                message: "Only lowercase letters, numbers, and hyphens",
              },
            })}
          />
    
          {/* Controlled component */}
          <Controller
            control={control}
            name="publishedAt"
            render={({ field }) => (
              <DatePicker {...field} />
            )}
          />
    
          <button type="submit">Save</button>
        </form>
      );
    };
    

    Ant Design Forms

    Native Ant Design integration:
    import { useForm } from "@refinedev/antd";
    import { Form, Input, Select } from "antd";
    
    const PostForm = () => {
      const { formProps, saveButtonProps, queryResult } = useForm();
    
      return (
        <Form {...formProps} layout="vertical">
          <Form.Item
            label="Title"
            name="title"
            rules={[{ required: true, message: "Title is required" }]}
          >
            <Input />
          </Form.Item>
    
          <Form.Item
            label="Status"
            name="status"
            rules={[{ required: true }]}
          >
            <Select>
              <Select.Option value="draft">Draft</Select.Option>
              <Select.Option value="published">Published</Select.Option>
            </Select>
          </Form.Item>
    
          <Form.Item>
            <Button {...saveButtonProps}>Save</Button>
          </Form.Item>
        </Form>
      );
    };
    

    Material UI Forms

    Material-UI with React Hook Form:
    import { useForm } from "@refinedev/react-hook-form";
    import { TextField, Button, Box } from "@mui/material";
    
    const PostForm = () => {
      const {
        refineCore: { onFinish, formLoading },
        register,
        handleSubmit,
        formState: { errors },
      } = useForm();
    
      return (
        <Box component="form" onSubmit={handleSubmit(onFinish)}>
          <TextField
            {...register("title", {
              required: "Title is required",
            })}
            label="Title"
            error={!!errors.title}
            helperText={errors.title?.message}
            fullWidth
            margin="normal"
          />
    
          <TextField
            {...register("content")}
            label="Content"
            multiline
            rows={4}
            fullWidth
            margin="normal"
          />
    
          <Button
            type="submit"
            variant="contained"
            disabled={formLoading}
          >
            Save
          </Button>
        </Box>
      );
    };
    

    Mantine Forms

    Native Mantine integration:
    import { useForm } from "@refinedev/mantine";
    import { TextInput, Textarea, Button, Select } from "@mantine/core";
    
    const PostForm = () => {
      const {
        getInputProps,
        saveButtonProps,
        errors,
      } = useForm({
        initialValues: {
          title: "",
          content: "",
          status: "draft",
        },
        validate: {
          title: (value) => (value.length < 3 ? "Too short" : null),
          content: (value) => (value.length < 10 ? "Too short" : null),
        },
      });
    
      return (
        <form>
          <TextInput
            label="Title"
            {...getInputProps("title")}
          />
    
          <Textarea
            label="Content"
            {...getInputProps("content")}
          />
    
          <Select
            label="Status"
            {...getInputProps("status")}
            data={[
              { value: "draft", label: "Draft" },
              { value: "published", label: "Published" },
            ]}
          />
    
          <Button {...saveButtonProps}>Save</Button>
        </form>
      );
    };
    

    Validation

    Client-Side Validation

    const { register } = useForm();
    
    <input
      {...register("email", {
        required: "Email is required",
        pattern: {
          value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
          message: "Invalid email address",
        },
      })}
    />
    

    Server-Side Validation

    Handle validation errors from the API:
    const { onFinish } = useForm({
      onMutationError: (error) => {
        // Handle validation errors from server
        if (error.statusCode === 422) {
          const validationErrors = error.errors;
          // Set field errors
          Object.entries(validationErrors).forEach(([field, messages]) => {
            setError(field, {
              type: "server",
              message: messages[0],
            });
          });
        }
      },
    });
    

    Custom Validation

    const { register, setError } = useForm();
    
    const validateUnique = async (value: string) => {
      const response = await fetch(`/api/check-slug?slug=${value}`);
      const { exists } = await response.json();
      
      if (exists) {
        setError("slug", {
          type: "manual",
          message: "Slug already exists",
        });
        return false;
      }
      
      return true;
    };
    
    <input
      {...register("slug", {
        validate: validateUnique,
      })}
    />
    

    Advanced Features

    Auto-Save

    Automatically save form changes:
    import { useForm } from "@refinedev/react-hook-form";
    import { useEffect } from "react";
    
    const PostEdit = () => {
      const {
        refineCore: { onFinish, autoSaveProps },
        register,
        watch,
      } = useForm({
        refineCoreProps: {
          action: "edit",
          autoSave: {
            enabled: true,
            debounce: 2000, // Wait 2s after last change
          },
        },
      });
    
      const values = watch();
    
      // Auto-save indicator
      const { data, error, status } = autoSaveProps;
    
      return (
        <div>
          {status === "loading" && <span>Saving...</span>}
          {status === "success" && <span>Saved ✓</span>}
          {status === "error" && <span>Error saving</span>}
          
          <form>
            <input {...register("title")} />
            <textarea {...register("content")} />
          </form>
        </div>
      );
    };
    

    Mutation Modes

    Control optimistic updates and undo behavior:
    const { onFinish } = useForm({
      mutationMode: "pessimistic",
    });
    
    // Waits for server response before updating UI
    // Safest option
    

    Query Invalidation

    Control which queries to refresh after mutation:
    const { onFinish } = useForm({
      invalidates: ["list", "many", "detail"],
      // or
      invalidates: false, // Don't invalidate anything
    });
    

    Redirection

    Customize post-submit navigation:
    const { onFinish, redirect } = useForm({
      redirect: "show", // "list" | "edit" | "show" | "create" | false
    });
    
    // Or use redirect function for custom logic
    onFinish(values).then((response) => {
      if (values.status === "published") {
        redirect("show", response.data.id);
      } else {
        redirect("edit", response.data.id);
      }
    });
    

    Notifications

    Customize success and error notifications:
    const { onFinish } = useForm({
      successNotification: (data, values, resource) => ({
        message: `${data.title} saved successfully`,
        description: "Good job!",
        type: "success",
      }),
      errorNotification: (error, values, resource) => ({
        message: "Failed to save",
        description: error.message,
        type: "error",
      }),
    });
    

    Handling Relationships

    Foreign Key Selection

    Use useSelect for dropdowns:
    import { useSelect } from "@refinedev/core";
    
    const PostForm = () => {
      const { register } = useForm();
      
      const { options, isLoading } = useSelect({
        resource: "categories",
        optionLabel: "title",
        optionValue: "id",
      });
    
      return (
        <select {...register("categoryId")}>
          <option value="">Select category</option>
          {options?.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      );
    };
    

    Search and Filter

    const { options, onSearch } = useSelect({
      resource: "users",
      onSearch: (value) => [
        {
          field: "name",
          operator: "contains",
          value,
        },
      ],
      debounce: 500,
    });
    
    <input
      type="search"
      onChange={(e) => onSearch(e.target.value)}
      placeholder="Search users..."
    />
    

    Many-to-Many Relationships

    const { options } = useSelect({
      resource: "tags",
      fetchSize: 100,
    });
    
    const { register, watch, setValue } = useForm();
    const selectedTags = watch("tags") || [];
    
    const toggleTag = (tagId: string) => {
      if (selectedTags.includes(tagId)) {
        setValue(
          "tags",
          selectedTags.filter((id) => id !== tagId)
        );
      } else {
        setValue("tags", [...selectedTags, tagId]);
      }
    };
    
    return (
      <div>
        {options?.map((tag) => (
          <label key={tag.value}>
            <input
              type="checkbox"
              checked={selectedTags.includes(tag.value)}
              onChange={() => toggleTag(tag.value)}
            />
            {tag.label}
          </label>
        ))}
      </div>
    );
    
    import { useModalForm } from "@refinedev/react-hook-form";
    import { Modal } from "your-ui-library";
    
    const PostList = () => {
      const {
        modal: { visible, close, show },
        refineCore: { onFinish },
        register,
        handleSubmit,
      } = useModalForm({
        refineCoreProps: {
          action: "create",
          resource: "posts",
        },
      });
    
      return (
        <div>
          <button onClick={show}>Create Post</button>
          
          <Modal open={visible} onClose={close}>
            <form onSubmit={handleSubmit(onFinish)}>
              <input {...register("title")} />
              <button type="submit">Save</button>
            </form>
          </Modal>
        </div>
      );
    };
    

    Drawer Form

    import { useDrawerForm } from "@refinedev/react-hook-form";
    import { Drawer } from "your-ui-library";
    
    const PostList = () => {
      const {
        drawer: { visible, close, show },
        refineCore: { onFinish },
        register,
        handleSubmit,
      } = useDrawerForm({
        refineCoreProps: {
          action: "edit",
          resource: "posts",
        },
      });
    
      return (
        <div>
          {posts.map((post) => (
            <div key={post.id}>
              {post.title}
              <button onClick={() => show(post.id)}>Edit</button>
            </div>
          ))}
          
          <Drawer open={visible} onClose={close}>
            <form onSubmit={handleSubmit(onFinish)}>
              <input {...register("title")} />
              <button type="submit">Update</button>
            </form>
          </Drawer>
        </div>
      );
    };
    

    Multi-Step Forms

    import { useStepsForm } from "@refinedev/react-hook-form";
    
    const PostCreate = () => {
      const {
        refineCore: { onFinish },
        register,
        handleSubmit,
        steps: { currentStep, gotoStep },
      } = useStepsForm({
        refineCoreProps: {
          resource: "posts",
        },
      });
    
      const renderStep = () => {
        switch (currentStep) {
          case 0:
            return (
              <div>
                <h2>Basic Info</h2>
                <input {...register("title")} />
                <input {...register("slug")} />
              </div>
            );
          case 1:
            return (
              <div>
                <h2>Content</h2>
                <textarea {...register("content")} />
              </div>
            );
          case 2:
            return (
              <div>
                <h2>Publishing</h2>
                <select {...register("status")}>
                  <option value="draft">Draft</option>
                  <option value="published">Published</option>
                </select>
              </div>
            );
          default:
            return null;
        }
      };
    
      return (
        <form onSubmit={handleSubmit(onFinish)}>
          {renderStep()}
          
          <div>
            {currentStep > 0 && (
              <button type="button" onClick={() => gotoStep(currentStep - 1)}>
                Previous
              </button>
            )}
            
            {currentStep < 2 ? (
              <button type="button" onClick={() => gotoStep(currentStep + 1)}>
                Next
              </button>
            ) : (
              <button type="submit">Submit</button>
            )}
          </div>
        </form>
      );
    };
    

    File Uploads

    Single File Upload

    const { register, setValue } = useForm();
    
    const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
    
      // Upload to your storage service
      const formData = new FormData();
      formData.append("file", file);
    
      const response = await fetch("/api/upload", {
        method: "POST",
        body: formData,
      });
    
      const { url } = await response.json();
      setValue("imageUrl", url);
    };
    
    return (
      <div>
        <input type="file" onChange={handleFileChange} accept="image/*" />
        <input {...register("imageUrl")} type="hidden" />
      </div>
    );
    

    Multiple Files

    const [files, setFiles] = useState<string[]>([]);
    
    const handleFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
      const uploadedUrls: string[] = [];
      
      for (const file of Array.from(e.target.files || [])) {
        const formData = new FormData();
        formData.append("file", file);
        
        const response = await fetch("/api/upload", {
          method: "POST",
          body: formData,
        });
        
        const { url } = await response.json();
        uploadedUrls.push(url);
      }
      
      setFiles([...files, ...uploadedUrls]);
      setValue("images", [...files, ...uploadedUrls]);
    };
    

    Best Practices

    1. Use proper validation - Validate on both client and server
    2. Handle loading states - Show spinners during save operations
    3. Implement auto-save carefully - Use appropriate debounce values
    4. Provide clear feedback - Show success/error notifications
    5. Handle unsaved changes - Warn users before leaving with unsaved data
    6. Use TypeScript - Type your form data for better DX
    7. Test edge cases - Handle network errors, validation errors, etc.
    8. Optimize re-renders - Use proper form state management

    Next Steps

    Build docs developers (and LLMs) love