Kunal Jain

useFormFlow: A form hook so our team doesn't write the same code 100 times

May 16, 2026


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:

  1. react-hook-form validates all fields against the schema
  2. If validation fails, the error callback fires and shows a toast
  3. If validation passes, beforeSubmit runs (if provided)
  4. If beforeSubmit returns false, submission stops
  5. If showConfirmation is true, a confirmation dialog appears
  6. User confirms → onSubmit is called
  7. On success → handleSuccess fires (onSuccess callback, toast, navigation, form reset, status update)
  8. 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:

BoilerplateBeforeAfter
Loading stateconst [isSubmitting, setIsSubmitting] = useState(false)isSubmitting returned by hook
Submit loading guardif (isSubmitting) return at top of handlerBuilt into handleSubmit
Error toasttoast.error(error.message) in catch blockHook does it, configure via toast prop
Success toasttoast.success("Saved") after API callHook does it, configure via toast prop
Confirmation dialogawait confirm({...}) before API callOpt out with showConfirmation: false
Form reset after saveform.reset() in success handlerOpt out with resetOnSuccess: false
Dirty navigation preventionwindow.addEventListener("beforeunload", ...)Opt in with navigation.preventNavigationIfDirty
Edit/view modeconst [isEditing, setIsEditing] = useState(true)isEditing / setIsEditing returned by hook
Form disabled statedisabled={!isEditing || isSubmitting} computed manuallyHook computes it from internal state
Status trackingconst [status, setStatus] = useState("idle")formStatus returned by hook
Cancel/resetManual form reset + state resetonCancel handles both
Navigation after saverouter.push(...) in success handlerPass 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.