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.
<input>, <select>) when the user submits the form.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.
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.
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:
defaultValues.content: your initial editor content.register('content'): signals to RHF that the field is tracked.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 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.
Instead of syncing on every keystroke, you might only need the final value when the user:
onBlur),<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.
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.
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.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}
<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):
initialValue is one-shot. For an explicit external reset, replace the
editor document once with tx.value.replace(...).tx.value.replace(...) fits the complete document, records one history change, and clears selection when none is supplied.