Submitting a form with a button that lives outside it
The problem
Modal and dialog layouts usually look something like this:
┌─────────────────────────────────┐ │ Header │ ├─────────────────────────────────┤ │ Content (scrollable) │ │ <YourForm /> │ ├─────────────────────────────────┤ │ Footer │ │ [Cancel] [Save Changes] │ └─────────────────────────────────┘
The <form> lives in the scrollable content area. The Save button lives in the footer — which is a sibling of the content in the DOM, not a descendant of the form. So type="submit" won't trigger it.
The usual workarounds are either ugly (call form.submit() imperatively, or lift state up) or they force you to restructure your JSX in awkward ways just to get the button inside the form.
The native fix: the form attribute
HTML has had a solution for this forever. Any <button>, <input>, <select>, or <textarea> can be associated with a form anywhere on the page using the form attribute — just point it at the form's id:
<form id="my-form" onsubmit="..."> <!-- fields --> </form> <!-- completely elsewhere in the DOM --> <button type="submit" form="my-form">Save</button>
Clicking that button behaves exactly as if it were inside the form. The browser handles it natively.
With React Hook Form
This plays perfectly with RHF. Give your form an id, put that same id on the external button, and everything — validation, error messages, the handleSubmit wrapper — works as normal:
function MyForm({ id }: { id: string }) { const { register, handleSubmit, formState: { errors } } = useForm<Fields>({ resolver: zodResolver(schema), }); return ( <form id={id} onSubmit={handleSubmit(onSubmit)}> <input {...register('name')} /> {errors.name && <p>{errors.name.message}</p>} </form> ); } function MyModal({ onClose }: { onClose: () => void }) { const formId = useId(); // stable unique ID — safe with SSR return ( <div className="modal"> <div className="modal-content"> <MyForm id={formId} /> </div> <div className="modal-footer"> <button type="button" onClick={onClose}>Cancel</button> <button type="submit" form={formId}>Save Changes</button> </div> </div> ); }
The footer button triggers RHF's handleSubmit, which validates first and only calls onSubmit if everything passes. No imperative refs, no lifted state.
Use useId() for the ID
React 18's useId is the right tool here. It generates a stable ID that's unique per component instance, works with SSR, and avoids collisions if the modal can be mounted more than once simultaneously.
const formId = useId();
Don't just hardcode id="my-form" unless you're certain only one instance will ever exist.
Stub forms for non-form tabs
One other pattern worth knowing: if you have a multi-tab modal with a single Save button in the footer, some tabs might not have a form at all (e.g. a read-only info tab). You can render a stub form with the same ID that just calls onClose:
// Read-only tab — no real form, but footer button still needs to work <form id={formId} onSubmit={(e) => { e.preventDefault(); onClose(); }} />
This keeps the footer button consistent across all tabs without special-casing the click handler.