From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting

Form

PreviousNext

How to integrate Plate editor with react-hook-form.

While Plate is typically used as an uncontrolled input, there are valid scenarios where you want to integrate the editor with react-hook-form and the Field family from shadcn/ui. This guide walks through best practices and common pitfalls.

When to Integrate Plate with a Form

  • Form Submission: You want the editor's content to be included along with other fields (e.g., <input>, <select>) when the user submits the form.
MarkdownTypeScript

On This Page

When to Integrate Plate with a FormApproach 1: Sync on onValueChangeReact Hook Form Exampleshadcn/ui Field ExampleApproach 2: Sync on Blur (or Another Trigger)Approach 3: Controlled Replacement (Advanced)Example: Save & ResetMigrating from a shadcn Textarea to PlateBest Practices
Build your editor
Production-ready AI template and reusable components.
Get all-access
  • Validation: You want to validate the editor's content (e.g., checking if it's empty) at the same time as other form fields.
  • Form Data Management: You want to store the editor content in the same store (like react-hook-form's state) as other fields.
  • However, keep in mind the warning about fully controlling the editor value. Plate strongly prefers an uncontrolled model. If you replace the editor's internal state too frequently, selection, history, and rendering all pay for full-document replacement. Treat the editor as uncontrolled and sync complete document values at deliberate form boundaries.

    Approach 1: Sync on onValueChange

    This is the most straightforward approach: each time the editor changes, update your form field's value. For small documents or infrequent changes, this is usually acceptable.

    React Hook Form Example

    import { useForm } from "react-hook-form";
    import { NodeApi, type EditorDocumentValue } from "platejs";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    type FormData = {
      content: EditorDocumentValue;
    };
     
    export function RHFEditorForm() {
      const initialValue = [
        { type: "paragraph", children: [{ text: "Hello from react-hook-form!" }] },
      ];
     
      // Setup react-hook-form
      const { register, handleSubmit, setValue } = useForm<FormData>({
        defaultValues: {
          content: { children: initialValue },
        },
      });
     
      // Create/configure the Plate editor
      const editor = usePlateEditor({ initialValue });
     
      // Register the field for react-hook-form
      register("content", {
        validate: (value) =>
          NodeApi.string({ children: value.children }).trim().length > 0 ||
          "Content is required",
      });
     
      const onSubmit = (data: FormData) => {
        // data.content will have final editor content
        console.info("Submitted:", data.content);
      };
     
      return (
        <form onSubmit={handleSubmit(onSubmit)}>
          <Plate
            editor={editor}
            onValueChange={({ value }) => {
              // Sync editor changes to the form
              setValue("content", value);
            }}
          >
            <PlateContent placeholder="Type here..." />
          </Plate>
     
          <button type="submit">Submit</button>
        </form>
      );
    }
    import { useForm } from "react-hook-form";
    import { NodeApi, type EditorDocumentValue } from "platejs";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    type FormData = {
      content: EditorDocumentValue;
    };
     
    export function RHFEditorForm() {
      const initialValue = [
        { type: "paragraph", children: [{ text: "Hello from react-hook-form!" }] },
      ];
     
      // Setup react-hook-form
      const { register, handleSubmit, setValue } = useForm<FormData
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    Notes:

    1. defaultValues.content: your initial editor content.
    2. register('content'): signals to RHF that the field is tracked.
    3. onValueChange({ value }): calls setValue('content', value) each time.

    If you expect large documents or fast typing, consider debouncing or switching to an onBlur approach to reduce form updates.

    shadcn/ui Field Example

    shadcn/ui provides field layout and validation components. Use React Hook Form's <Controller> to connect Plate to that UI:

    import {
      Field,
      FieldError,
      FieldLabel,
    } from "@/components/ui/field";
    import { Controller, useForm } from "react-hook-form";
    import type { EditorDocumentValue } from "platejs";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    type FormValues = {
      content: EditorDocumentValue;
    };
     
    export function EditorForm() {
      // 1. Create the form
      const form = useForm<FormValues>({
        defaultValues: {
          content: {
            children: [
              { type: "paragraph", children: [{ text: "Hello from shadcn/ui Field!" }] },
            ],
          },
        },
      });
     
      // 2. Create the Plate editor
      const editor = usePlateEditor({
        initialValue: form.getValues("content"),
      });
     
      const onSubmit = (data: FormValues) => {
        console.info("Submitted data:", data.content);
      };
     
      return (
        <form onSubmit={form.handleSubmit(onSubmit)}>
            <Controller
              control={form.control}
              name="content"
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel>Content</FieldLabel>
                  <Plate
                    editor={editor}
                    onValueChange={({ value }) => field.onChange(value)}
                  >
                    <PlateContent
                      aria-invalid={fieldState.invalid}
                      placeholder="Type..."
                    />
                  </Plate>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
     
            <button type="submit">Submit</button>
        </form>
      );
    }
    import {
      Field,
      FieldError,
      FieldLabel,
    } from "@/components/ui/field";
    import { Controller, useForm } from "react-hook-form";
    import type { EditorDocumentValue } from "platejs";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    type FormValues = {
      content: EditorDocumentValue;
    };
     
    export function EditorForm() {
      // 1. Create the form
      const form = useForm<FormValues>({
        defaultValues: {
          content: {
            children: [
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    This approach makes editor content behave like any other React Hook Form field while shadcn/ui owns layout and validation styling.

    Approach 2: Sync on Blur (or Another Trigger)

    Instead of syncing on every keystroke, you might only need the final value when the user:

    • Leaves the editor (onBlur),
    • Clicks a “Save” button, or
    • Reaches certain form submission logic.
    <Plate editor={editor}>
      <PlateContent
        onBlur={() => {
          // Only sync on blur
          setValue("content", editor.read.value());
        }}
      />
    </Plate>
    <Plate editor={editor}>
      <PlateContent
        onBlur={() => {
          // Only sync on blur
          setValue("content", editor.read.value());
        }}
      />
    </Plate>

    This reduces overhead but your form state won't reflect partial updates while the user is typing.

    Approach 3: Controlled Replacement (Advanced)

    If you want the form to be the single source of truth (completely controlled):

    editor.update((tx) => {
      tx.value.replace(formStateValue);
    });
    editor.update((tx) => {
      tx.value.replace(formStateValue);
    });

    Use this only for an explicit external snapshot. A replacement uses the supplied selection or clears it when selection is omitted, records one history change, removes omitted roots, and resets omitted persisted meta. Mirroring every onValueChange back through this API creates a replacement loop and full-document rendering work.

    Recommendation: Stick to a partially uncontrolled model if you can.

    Example: Save & Reset

    Here's a more complete form demonstrating both saving and resetting the editor/form:

    import { useForm } from "react-hook-form";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    function MyForm() {
      const form = useForm({
        defaultValues: {
          content: {
            children: [{ type: "paragraph", children: [{ text: "Initial content..." }] }],
          },
        },
      });
     
      const editor = usePlateEditor({
        initialValue: form.getValues("content"),
      });
     
      const onSubmit = (data) => {
        alert(JSON.stringify(data, null, 2));
      };
     
      return (
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <Plate
            editor={editor}
            onValueChange={({ value }) => form.setValue("content", value)}
          >
            <PlateContent />
          </Plate>
     
          <button type="submit">Save</button>
     
          <button
            type="button"
            onClick={() => {
              form.reset();
              editor.update((tx) => {
                tx.value.replace(form.getValues("content"));
              });
            }}
          >
            Reset
          </button>
        </form>
      );
    }
    import { useForm } from "react-hook-form";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    function MyForm() {
      const form = useForm({
        defaultValues: {
          content: {
            children: [{ type: "paragraph", children: [{ text: "Initial content..." }] }],
          },
        },
      });
     
      const editor = usePlateEditor({
        initialValue: form.getValues("content"),
      });
     
      const onSubmit = (data) => {
        alert(JSON
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    • onValueChange -> updates form state.
    • Reset -> resets the form and replaces the editor value from the reset form state.

    Migrating from a shadcn Textarea to Plate

    If you have a standard Field example from shadcn/ui docs and want to replace <Textarea> with a Plate editor, follow these steps:

    // 1. Original code (TextareaForm)
    <Controller
      control={form.control}
      name="bio"
      render={({ field, fieldState }) => (
        <Field data-invalid={fieldState.invalid}>
          <FieldLabel>Bio</FieldLabel>
          <Textarea
            aria-invalid={fieldState.invalid}
            placeholder="Tell us a bit about yourself"
            className="resize-none"
            {...field}
          />
          <FieldDescription>
            You can <span>@mention</span> other users and organizations.
          </FieldDescription>
          <FieldError errors={[fieldState.error]} />
        </Field>
      )}
    />
    // 1. Original code (TextareaForm)
    <Controller
      control={form.control}
      name="bio"
      render={({ field, fieldState }) => (
        <Field data-invalid={fieldState.invalid}>
          <FieldLabel>Bio</FieldLabel>
          <Textarea
            aria-invalid={fieldState.invalid}
            placeholder="Tell us a bit about yourself"
            className="resize-none"
            {...field}
          />
          <FieldDescription>
            You can <span>@mention</span> other users and organizations.
          </
    
    
    
    

    Create a new EditorField component:

    // EditorField.tsx
    "use client";
     
    import * as React from "react";
    import type { EditorDocumentValue, Value } from "platejs";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    /**
     * A reusable uncontrolled editor field. `initialValue` is read once, while
     * `onChange` receives the complete document after each change.
     *
     * Usage:
     *
     * <Controller
     *   control={form.control}
     *   name="bio"
     *   render={({ field, fieldState }) => (
     *     <Field data-invalid={fieldState.invalid}>
     *       <FieldLabel>Bio</FieldLabel>
     *       <EditorField
     *         aria-invalid={fieldState.invalid}
     *         initialValue={field.value}
     *         onBlur={field.onBlur}
     *         onChange={field.onChange}
     *         placeholder="Tell us a bit about yourself"
     *         ref={field.ref}
     *       />
     *       <FieldDescription>Some helpful description...</FieldDescription>
     *       <FieldError errors={[fieldState.error]} />
     *     </Field>
     *   )}
     * />
     */
    export type EditorFieldProps = Omit<
      React.ComponentPropsWithRef<"div">,
      "onChange"
    > & {
      /**
       * The initial Plate document or primary-root value.
       */
      initialValue?: EditorDocumentValue | Value;
     
      /**
       * Called when the editor value changes.
       */
      onChange?: (value: EditorDocumentValue) => void;
     
      /**
       * Placeholder text to display when editor is empty.
       */
      placeholder?: string;
    };
     
    export function EditorField({
      initialValue,
      onChange,
      placeholder = "Type here...",
      ref,
      ...props
    }: EditorFieldProps) {
      const editor = usePlateEditor({
        initialValue: initialValue ?? [{ type: "paragraph", children: [{ text: "" }] }],
      });
     
      return (
        <Plate editor={editor} onValueChange={({ value }) => onChange?.(value)}>
          <PlateContent {...props} placeholder={placeholder} ref={ref} />
        </Plate>
      );
    }
    // EditorField.tsx
    "use client";
     
    import * as React from "react";
    import type { EditorDocumentValue, Value } from "platejs";
    import { Plate, PlateContent, usePlateEditor } from "platejs/react";
     
    /**
     * A reusable uncontrolled editor field. `initialValue` is read once, while
     * `onChange` receives the complete document after each change.
     *
     * Usage:
     *
     * <Controller
     *   control={form.control}
     *   name="bio"
     *   render={({ field, fieldState }) => (
     *     <Field data-invalid={fieldState.invalid}>
     *       <FieldLabel>Bio</FieldLabel>
     *       <EditorField
     *         aria-invalid={fieldState.invalid}
     *         initialValue={field.value}
     *         onBlur={field.onBlur}
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    1. Replace the <Textarea> with a <EditorField> block:
    "use client";
     
    import { z } from "zod";
    import { Controller, useForm } from "react-hook-form";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { NodeApi, type EditorDocumentValue } from "platejs";
     
    import {
      Field,
      FieldDescription,
      FieldError,
      FieldLabel,
    } from "@/components/ui/field";
    import { EditorField } from "./EditorField"; // Import the component above
     
    // 1. Define our validation schema with zod
    const hasDocumentText = (value: unknown): value is EditorDocumentValue =>
      typeof value === "object" &&
      value !== null &&
      "children" in value &&
      NodeApi.isNodeList(value.children, { deep: true }) &&
      value.children.some((node) => NodeApi.string(node).trim().length > 0);
     
    const FormSchema = z.object({
      bio: z.custom<EditorDocumentValue>(hasDocumentText, {
        message: "Bio is required.",
      }),
    });
     
    // 2. Build our main form component
    export function EditorForm() {
      // 3. Setup the form
      const form = useForm<z.infer<typeof FormSchema>>({
        resolver: zodResolver(FormSchema),
        defaultValues: {
          bio: {
            children: [{ type: "paragraph", children: [{ text: "" }] }],
          },
        },
      });
     
      // 4. Submission handler
      function onSubmit(data: z.infer<typeof FormSchema>) {
        alert("Submitted: " + JSON.stringify(data, null, 2));
      }
     
      return (
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
            <Controller
              control={form.control}
              name="bio"
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel>Bio</FieldLabel>
                  <EditorField
                    aria-invalid={fieldState.invalid}
                    initialValue={field.value}
                    onBlur={field.onBlur}
                    onChange={field.onChange}
                    placeholder="Tell us a bit about yourself..."
                    ref={field.ref}
                  />
                  <FieldDescription>
                    You can <span>@mention</span> other users and organizations.
                  </FieldDescription>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <button type="submit" className="py-2 px-4 bg-primary text-white">
              Submit
            </button>
        </form>
      );
    }
    "use client";
     
    import { z } from "zod";
    import { Controller, useForm } from "react-hook-form";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { NodeApi, type EditorDocumentValue } from "platejs";
     
    import {
      Field,
      FieldDescription,
      FieldError,
      FieldLabel,
    } from "@/components/ui/field";
    import { EditorField } from "./EditorField"; // Import the component above
     
    // 1. Define our validation schema with zod
    const hasDocumentText = (value: unknown):
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    • Validate and persist the complete document value.
    • initialValue is one-shot. For an explicit external reset, replace the editor document once with tx.value.replace(...).

    Best Practices

    1. Use an Uncontrolled Editor: Let Plate manage its own state, updating the form only when necessary.
    2. Minimize Replacements: Each tx.value.replace(...) fits the complete document, records one history change, and clears selection when none is supplied.
    3. Validate at the Right Time: Decide if you need instant validation (typing) or upon blur/submit.
    4. Reset Both: If you reset the form, replace the editor value from the reset form state.
    >({
    defaultValues: {
    content: { children: initialValue },
    },
    });
    // Create/configure the Plate editor
    const editor = usePlateEditor({ initialValue });
    // Register the field for react-hook-form
    register("content", {
    validate: (value) =>
    NodeApi.string({ children: value.children }).trim().length > 0 ||
    "Content is required",
    });
    const onSubmit = (data: FormData) => {
    // data.content will have final editor content
    console.info("Submitted:", data.content);
    };
    return (
    <form onSubmit={handleSubmit(onSubmit)}>
    <Plate
    editor={editor}
    onValueChange={({ value }) => {
    // Sync editor changes to the form
    setValue("content", value);
    }}
    >
    <PlateContent placeholder="Type here..." />
    </Plate>
    <button type="submit">Submit</button>
    </form>
    );
    }
    { type: "paragraph", children: [{ text: "Hello from shadcn/ui Field!" }] },
    ],
    },
    },
    });
    // 2. Create the Plate editor
    const editor = usePlateEditor({
    initialValue: form.getValues("content"),
    });
    const onSubmit = (data: FormValues) => {
    console.info("Submitted data:", data.content);
    };
    return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
    <Controller
    control={form.control}
    name="content"
    render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
    <FieldLabel>Content</FieldLabel>
    <Plate
    editor={editor}
    onValueChange={({ value }) => field.onChange(value)}
    >
    <PlateContent
    aria-invalid={fieldState.invalid}
    placeholder="Type..."
    />
    </Plate>
    <FieldError errors={[fieldState.error]} />
    </Field>
    )}
    />
    <button type="submit">Submit</button>
    </form>
    );
    }
    .
    stringify
    (data,
    null
    ,
    2
    ));
    };
    return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
    <Plate
    editor={editor}
    onValueChange={({ value }) => form.setValue("content", value)}
    >
    <PlateContent />
    </Plate>
    <button type="submit">Save</button>
    <button
    type="button"
    onClick={() => {
    form.reset();
    editor.update((tx) => {
    tx.value.replace(form.getValues("content"));
    });
    }}
    >
    Reset
    </button>
    </form>
    );
    }
    FieldDescription
    >
    <FieldError errors={[fieldState.error]} />
    </Field>
    )}
    />
    * onChange={field.onChange}
    * placeholder="Tell us a bit about yourself"
    * ref={field.ref}
    * />
    * <FieldDescription>Some helpful description...</FieldDescription>
    * <FieldError errors={[fieldState.error]} />
    * </Field>
    * )}
    * />
    */
    export type EditorFieldProps = Omit<
    React.ComponentPropsWithRef<"div">,
    "onChange"
    > & {
    /**
    * The initial Plate document or primary-root value.
    */
    initialValue?: EditorDocumentValue | Value;
    /**
    * Called when the editor value changes.
    */
    onChange?: (value: EditorDocumentValue) => void;
    /**
    * Placeholder text to display when editor is empty.
    */
    placeholder?: string;
    };
    export function EditorField({
    initialValue,
    onChange,
    placeholder = "Type here...",
    ref,
    ...props
    }: EditorFieldProps) {
    const editor = usePlateEditor({
    initialValue: initialValue ?? [{ type: "paragraph", children: [{ text: "" }] }],
    });
    return (
    <Plate editor={editor} onValueChange={({ value }) => onChange?.(value)}>
    <PlateContent {...props} placeholder={placeholder} ref={ref} />
    </Plate>
    );
    }
    value
    is
    EditorDocumentValue
    =>
    typeof value === "object" &&
    value !== null &&
    "children" in value &&
    NodeApi.isNodeList(value.children, { deep: true }) &&
    value.children.some((node) => NodeApi.string(node).trim().length > 0);
    const FormSchema = z.object({
    bio: z.custom<EditorDocumentValue>(hasDocumentText, {
    message: "Bio is required.",
    }),
    });
    // 2. Build our main form component
    export function EditorForm() {
    // 3. Setup the form
    const form = useForm<z.infer<typeof FormSchema>>({
    resolver: zodResolver(FormSchema),
    defaultValues: {
    bio: {
    children: [{ type: "paragraph", children: [{ text: "" }] }],
    },
    },
    });
    // 4. Submission handler
    function onSubmit(data: z.infer<typeof FormSchema>) {
    alert("Submitted: " + JSON.stringify(data, null, 2));
    }
    return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
    <Controller
    control={form.control}
    name="bio"
    render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
    <FieldLabel>Bio</FieldLabel>
    <EditorField
    aria-invalid={fieldState.invalid}
    initialValue={field.value}
    onBlur={field.onBlur}
    onChange={field.onChange}
    placeholder="Tell us a bit about yourself..."
    ref={field.ref}
    />
    <FieldDescription>
    You can <span>@mention</span> other users and organizations.
    </FieldDescription>
    <FieldError errors={[fieldState.error]} />
    </Field>
    )}
    />
    <button type="submit" className="py-2 px-4 bg-primary text-white">
    Submit
    </button>
    </form>
    );
    }