The problem
We work on a finance application. Loan applications, customer profiles, payment entries, configuration settings, approval workflows - you name it, we have a form for it. Over 100 forms spread across 17 feature modules.
Every single one of those forms needed the same things:
- A loading state so the submit button doesn't get double-clicked
- An error toast when the API call fails
- A success toast when it works
- A confirmation dialog before destructive submits
- The form to reset after success
- A dirty check so you don't lose unsaved changes
- An edit/view toggle so users can review before editing
- Form-level disabled state when not in edit mode or while submitting
Without a shared solution, every developer wrote these differently. Some used
useState for loading. Others used a useRef. Some showed errors in a
top-level alert. Others used toasts. The confirmation dialog was wired up in
five different ways across five modules. The dirty check existed in some forms
but not others.
A typical form file looked like this:
export function CreateSomethingForm({ onSuccess }: Props) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [formStatus, setFormStatus] = useState("idle")
const form = useForm({ resolver: yupResolver(schema) })
const router = useRouter()
const mutation = useCreateMutation()
async function handleSubmit(data: FormData) {
setIsSubmitting(true)
try {
const confirmed = await confirm({
title: "Are you sure?",
message: "This will create a new record.",
})
if (!confirmed) {
setIsSubmitting(false)
return
}
const response = await mutation.mutateAsync(data)
setFormStatus("succeeded")
toast.success("Created successfully")
form.reset()
onSuccess?.()
router.push("/listing")
} catch (error) {
setFormStatus("failed")
toast.error(error.message || "Something went wrong")
} finally {
setIsSubmitting(false)
}
}
return (
<Form onSubmit={form.handleSubmit(handleSubmit)}>
{/* form fields */}
<Button type="submit" loading={isSubmitting}>Save</Button>
</Form>
)
}
Every form had this exact 30-line skeleton before any fields were added. The fields themselves were another 50-80 lines. A single form file easily hit 100-120 lines. And every one of those 100 forms had its own copy, with its own variations and bugs.
The breaking point came when a simple "add a field to this form" task required reading three other forms to figure out which pattern to copy - and the pattern we copied had a bug in it.
What we built
We built a hook called useFormFlow. It wraps react-hook-form and adds a
standard submit pipeline. Here's the actual code:
import { FormEvent, useCallback, useEffect, useRef, useState } from "react"
import { FieldValues, useForm, UseFormProps, UseFormReturn } from "react-hook-form"
import { toast } from "sonner"
import { useConfirm } from "@/components/ui/confirm-dialog"
import { useRouter } from "next/navigation"
interface ConfirmationConfig {
message?: string
title?: string
type?: "warning" | "info" | "destructive" | "success"
}
interface NavigationConfig {
redirectAfterSuccess?: string
preventNavigationIfDirty?: boolean
}
interface ToastConfig {
showSuccessToast?: boolean
showErrorToast?: boolean
successMessage?: string
errorMessage?: string
validationErrorMessage?: string
}
export interface UseFormFlowProps<T extends FieldValues, R = any>
extends UseFormProps<T> {
showConfirmation?: boolean
beforeSubmit?: (data: T) => boolean
isEditing?: boolean
setIsEditing?: (value: boolean) => void
onSubmit: (data: T) => Promise<R>
onSuccess?: (response: R) => void
onError?: (error: any) => void
onCancel?: () => void
disabled?: boolean
confirmation?: ConfirmationConfig
navigation?: NavigationConfig
toast?: ToastConfig
resetOnSuccess?: boolean
disableOnSuccess?: boolean
disableOnCancel?: boolean
preserveErrorsOnReset?: boolean
}
export function useFormFlow<T extends FieldValues, R = any>({
beforeSubmit,
onSubmit,
onSuccess,
onError,
onCancel,
disableOnSuccess = true,
disableOnCancel = true,
showConfirmation = true,
disabled: initialDisabled = true,
confirmation = {
message: "Are you sure you want to continue?",
title: "Confirm Action",
type: "warning",
},
navigation,
toast: toastConfiguration,
resetOnSuccess = true,
preserveErrorsOnReset = false,
isEditing,
setIsEditing,
...formProps
}: UseFormFlowProps<T, R>): UseFormFlowReturn<T> {
const router = useRouter()
const [isEditingInternal, setIsEditingInternal] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formStatus, setFormStatus] =
useState<"idle" | "submitting" | "succeeded" | "failed">("idle")
const toastConfig = {
showSuccessToast: true,
showErrorToast: true,
successMessage: null,
errorMessage: null,
validationErrorMessage: "Please fix form errors!",
...toastConfiguration,
}
const confirm = useConfirm()
const form = useForm({
mode: "onBlur",
...formProps,
disabled: isEditingInternal === false || isSubmitting === true,
})
// The core submit handler
const handleSubmit = async (data: T) => {
if (isSubmitting) {
toast.warning("Already submitting. Please wait.")
return
}
function handleSuccess(response: R) {
onSuccess?.(response)
if (resetOnSuccess) {
form.reset(form.getValues(), {
keepErrors: preserveErrorsOnReset,
})
}
if (disableOnSuccess) onIsEditingChange(false)
if (navigation?.redirectAfterSuccess) {
router.push(navigation.redirectAfterSuccess)
}
if (toastConfig.showSuccessToast) {
const msg = (response as { message?: string }).message
if (msg && msg.trim().length > 10) {
toast.success("Success", { description: msg })
} else if (toastConfig.successMessage) {
toast.success("Success", { description: toastConfig.successMessage })
} else {
toast.success("Success", { description: "Saved successfully" })
}
}
setFormStatus("succeeded")
}
try {
let response: R | null = null
if (showConfirmation) {
let confirmErr = null
await confirm({
type: confirmation.type || "warning",
title: confirmation.title,
description: confirmation.message,
onConfirm: async () => {
setFormStatus("submitting")
setIsSubmitting(true)
try {
response = await onSubmit(data)
handleSuccess(response)
} catch (err) {
confirmErr = err
}
},
})
if (confirmErr) throw confirmErr
} else {
setFormStatus("submitting")
setIsSubmitting(true)
response = await onSubmit(data)
handleSuccess(response)
}
} catch (error: any) {
setFormStatus("failed")
onError?.(error)
if (toastConfig.showErrorToast) {
toast.error("Error", {
description: error.message ||
toastConfig.errorMessage ||
"Something went wrong",
duration: 20000,
closeButton: true,
})
}
} finally {
setIsSubmitting(false)
}
}
// The form onSubmit handler - wraps react-hook-form's handleSubmit
// to run validation first, then our pipeline
const handleFormSubmit = async (e?: FormEvent<HTMLFormElement>) => {
e?.preventDefault()
e?.stopPropagation()
form.handleSubmit(
async (data) => {
if (beforeSubmit && !beforeSubmit(data)) return
await handleSubmit(data)
},
(errors) => {
console.error(errors)
toast.error("Error", {
description: toastConfig.validationErrorMessage,
})
},
)(e)
}
const onIsEditingChange = useCallback(
(value: boolean) => {
setIsEditingInternal(value)
setIsEditing?.(value)
},
[setIsEditing],
)
const handleCancel = () => {
setFormStatus("idle")
form.reset(undefined, { keepErrors: false })
if (disableOnCancel) onIsEditingChange(false)
onCancel?.()
}
// Sync initial disabled state or external isEditing
useEffect(() => {
if (isEditing === undefined) {
setIsEditingInternal(!initialDisabled)
} else {
setIsEditingInternal(isEditing)
}
}, [initialDisabled, isEditing])
return {
...form,
isEditing: isEditingInternal,
setIsEditing: onIsEditingChange,
onSubmit: handleFormSubmit,
onCancel: handleCancel,
isSubmitting,
isDirty: form.formState.isDirty,
resetForm: (values?: T) => {
form.reset(values, { keepErrors: preserveErrorsOnReset })
setFormStatus("idle")
},
formStatus,
}
}
Let's walk through the key parts.
The submit pipeline
The hook composes react-hook-form's handleSubmit with its own pipeline.
When a user clicks submit, this happens in order:
- react-hook-form validates all fields against the schema
- If validation fails, the error callback fires and shows a toast
- If validation passes,
beforeSubmitruns (if provided) - If
beforeSubmitreturnsfalse, submission stops - If
showConfirmationis true, a confirmation dialog appears - User confirms →
onSubmitis called - On success →
handleSuccessfires (onSuccess callback, toast, navigation, form reset, status update) - On error → catch block fires (onError callback, error toast, status update)
Everything is wrapped in try/catch/finally so isSubmitting always gets
reset, even if something throws unexpectedly.
Why preventDefault and stopPropagation
You'll notice handleFormSubmit calls both:
const handleFormSubmit = async (e?: FormEvent<HTMLFormElement>) => {
e?.preventDefault()
e?.stopPropagation()
// ...
}
preventDefault() stops the browser from doing a full page reload when a
form is submitted - this is standard for any React form.
stopPropagation() prevents the submit event from bubbling up the DOM tree.
This matters when a form is rendered inside a dialog or a modal, which might
itself be inside another form. Without stopPropagation, clicking submit on
the inner form would also trigger the outer form's submit handler. In a
finance app where forms frequently appear inside modals and dialogs, this
is a real problem - you don't want submitting a "Add Remark" dialog to also
submit the loan application form underneath it.
react-hook-form's form.handleSubmit() calls preventDefault internally,
but it does not call stopPropagation. Adding both explicitly ensures the
hook works correctly regardless of where the form is rendered.
What the hook eliminates
Here's a checklist of things every form writer had to do manually before
useFormFlow. With the hook, all of these are handled automatically:
| Boilerplate | Before | After |
|---|---|---|
| Loading state | const [isSubmitting, setIsSubmitting] = useState(false) | isSubmitting returned by hook |
| Submit loading guard | if (isSubmitting) return at top of handler | Built into handleSubmit |
| Error toast | toast.error(error.message) in catch block | Hook does it, configure via toast prop |
| Success toast | toast.success("Saved") after API call | Hook does it, configure via toast prop |
| Confirmation dialog | await confirm({...}) before API call | Opt out with showConfirmation: false |
| Form reset after save | form.reset() in success handler | Opt out with resetOnSuccess: false |
| Dirty navigation prevention | window.addEventListener("beforeunload", ...) | Opt in with navigation.preventNavigationIfDirty |
| Edit/view mode | const [isEditing, setIsEditing] = useState(true) | isEditing / setIsEditing returned by hook |
| Form disabled state | disabled={!isEditing || isSubmitting} computed manually | Hook computes it from internal state |
| Status tracking | const [status, setStatus] = useState("idle") | formStatus returned by hook |
| Cancel/reset | Manual form reset + state reset | onCancel handles both |
| Navigation after save | router.push(...) in success handler | Pass navigation.redirectAfterSuccess |
What forms look like with it
Here are four real examples from our codebase.
1. A simple create dialog
This is a form inside a modal for creating a new record:
const { control, onSubmit } = useFormFlow({
onSubmit: (data) => createMutation.mutateAsync(data),
onSuccess: closeDialog,
resolver: yupResolver(schema),
disabled: false,
})
Six lines. The submit button gets loading={isSubmitting}. Success closes the
dialog. Errors show as toasts. The confirmation dialog appears automatically.
No manual loading state, no try/catch, no toast calls.
Without the hook, this was 30-40 lines of wiring.
2. An edit form with server data
This is a tab that shows company details. It starts in view mode. Clicking "Edit" enables the fields. Saving refetches the data:
const { control, onSubmit, isEditing, setIsEditing } = useFormFlow({
onSubmit: async (data) => updateMutation.mutateAsync(data),
values: query.data,
onSuccess: () => query.refetch(),
resolver: yupResolver(schema),
})
The values prop syncs the form with server data (unlike defaultValues
which only sets values on mount). Every time query.data changes, the form
re-populates. onSuccess refetches so the tab shows the latest data. The
hook handles disabling the form when not in edit mode.
The template looks like this:
<Form onSubmit={onSubmit}>
{/* fields */}
<div>
{isEditing ? (
<>
<Button type="submit" loading={isSubmitting}>Save</Button>
<Button type="button" onClick={onCancel}>Cancel</Button>
</>
) : (
<Button type="button" onClick={() => setIsEditing(true)}>Edit</Button>
)}
</div>
</Form>
No manual state, no conditional disabled logic. Just isEditing and
setIsEditing.
3. A form with cross-field validation
Some validations can't be expressed in Yup or Zod. For example, checking that a contract end date isn't before the start date:
const { control, onSubmit } = useFormFlow({
onSubmit: async (data) => mutation.mutateAsync(data),
beforeSubmit: (data) => {
if (data.startDate > data.endDate) {
toast.error("End date cannot be before start date")
return false
}
return true
},
resolver: yupResolver(schema),
confirmation: {
title: "Save changes?",
message: "This will update the contract terms.",
},
})
beforeSubmit runs after validation passes but before the confirmation
dialog. Return false and submission stops. Return true and the pipeline
continues. No special state management, no extra wiring.
4. A form with two-step submission
This form first verifies the data via a review API, then saves:
const { control, onSubmit } = useFormFlow({
disabled: false,
showConfirmation: false,
resetOnSuccess: false,
toast: { showSuccessToast: false },
beforeSubmit: (data) => {
if (data.expiryDate < data.issueDate) {
toast.error("Expiry date cannot be before issue date")
return false
}
return true
},
onSubmit: async (data) => {
if (isVerified) {
return onSave(data)
}
return reviewMutation.mutateAsync(data)
},
})
The onSubmit function branches based on a isVerified state. The first
submission calls the review API. If it succeeds, the UI switches to verified
mode, and the next submission calls the actual save. The hook handles all the
loading, error, and toast logic for both paths.
The hidden details
Nested form safety
As mentioned earlier, e.stopPropagation() in handleFormSubmit prevents
submit events from bubbling up. This is critical because forms in dialogs
are common:
<form> <!-- Parent form: loan application -->
...
<dialog>
<form> <!-- Child form: add remark -->
...
<button type="submit">Submit</button>
</form>
</dialog>
</form>
Without stopPropagation, clicking "Submit" in the add-remark dialog would
also trigger the loan application form's submit handler. The stopPropagation
call ensures only the inner form's handler fires.
The tradeoffs we made
We made three decisions that won't work for every project.
Forms start disabled by default. The default value of disabled is
true. This is because most of our forms are edit forms that should be
read-only until the user clicks "Edit." Create forms pass disabled: false
explicitly. If your app is mostly create forms, you'd want the opposite
default.
Confirmation dialog is on by default. Finance apps deal with money. An
accidental form submission can mean real losses. Every form shows a "Are you
sure?" dialog unless you pass showConfirmation: false. If you're building a
social media app, this would be annoying.
Per-field disabled isn't supported. react-hook-form lets you disable
individual fields. Our hook overrides the disabled prop entirely because it
needs to lock the whole form during submission and edit mode. You can't
disable one field while keeping others enabled. This hasn't been an issue for
us, but it's worth knowing.
The result
Forms that used to be 80-120 lines are now 30-50 lines. More importantly, every form behaves the same way:
- Same toast style for success and error
- Same confirmation dialog appearance
- Same loading state on submit buttons
- Same error handling (catch → toast → reset submitting state)
- Same cancel behavior (reset → disable → callback)
When we fixed a bug in the hook (the success toast was firing before navigation completed, causing a status flicker), every form got the fix - not just the ones we remembered to update.
The hook is about 280 lines. It paid for itself after the third form.
If you have more than a handful of forms in your app, building something like this is worth the investment. You don't have to get it right on day one. Start with just the submit pipeline and add features as you see the same patterns repeated across your codebase.