Skip to main content
New to FormEngine? It’s a React library that renders forms from JSON schemas — free, MIT-licensed. Start here →
Once your form collects data, you need to send it somewhere — an API, a database, an email service. This guide covers every common submission pattern: simple POST, validation before submit, loading state, server errors, multi-step, optimistic updates.

The submit button pattern

FormEngine fires events on components, including buttons. Attach an onClick action to a button and you get access to the current form data, validation state, and a way to update both.
import { FormViewer, ActionDefinition, IFormData } from '@react-form-builder/core'
import { view } from '@react-form-builder/components-rsuite'

const customActions = {
  submitForm: ActionDefinition.functionalAction(async (e) => {
    const validation = await e.form.getValidationResult()
    const hasErrors = validation && Object.keys(validation).length > 0
    if (hasErrors) return

    const response = await fetch('/api/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(e.data),
    })

    if (!response.ok) throw new Error('Submit failed')
    const result = await response.json()
    console.log('Server response:', result)
  }),
}

export default function App() {
  return <FormViewer view={view} getForm={getForm} actions={customActions} />
}
In the form JSON, wire the action to the button’s onClick:
{
  "key": "submit",
  "type": "RsButton",
  "props": { "children": { "value": "Submit" } },
  "events": { "onClick": { "name": "submitForm", "type": "common" } }
}

Submit from outside the form (viewerRef)

If your submit button is outside <FormViewer> (e.g., a toolbar or modal footer), use viewerRef:
import { useRef } from 'react'
import { FormViewer, IFormViewer, IFormData } from '@react-form-builder/core'

export default function App() {
  const viewerRef = useRef<IFormViewer>(null)

  const handleSubmit = async () => {
    const viewer = viewerRef.current
    if (!viewer) return

    const formData = viewer.formData as IFormData
    const validation = await formData.getValidationResult()
    if (validation && Object.keys(validation).length > 0) {
      console.log('Validation errors:', validation)
      return
    }

    await postToServer(formData.data)
  }

  return (
    <>
      <FormViewer view={view} getForm={getForm} viewerRef={viewerRef} />
      <button onClick={handleSubmit}>Send</button>
    </>
  )
}

Loading state during submit

Track submit state at the app level and disable the form while waiting:
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)

const handleSubmit = async () => {
  setSubmitting(true)
  setError(null)
  try {
    const formData = viewerRef.current?.formData as IFormData
    await postToServer(formData.data)
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Submit failed')
  } finally {
    setSubmitting(false)
  }
}

return (
  <>
    <FormViewer
      view={view}
      getForm={getForm}
      viewerRef={viewerRef}
      disabled={submitting}
    />
    {error && <div role="alert">{error}</div>}
    <button onClick={handleSubmit} disabled={submitting}>
      {submitting ? 'Sending…' : 'Submit'}
    </button>
  </>
)
The disabled prop on FormViewer freezes every field that supports it — no accidental edits mid-request.

Server-side validation errors

When the backend returns field-level errors, map them back to form fields via initialData updates or custom validations.
const handleSubmit = async () => {
  const response = await fetch('/api/submit', { /* ... */ })
  if (response.status === 422) {
    const body = await response.json()
    // body: { fieldErrors: { email: "Already registered" } }
    setServerErrors(body.fieldErrors)
  }
}
Render server errors near each field by wiring them into a custom validation action or by displaying an inline alert referencing the field key.

Optimistic updates

For fast-feeling UX, update app state before the server replies and roll back on failure:
const handleSubmit = async (data: FormShape) => {
  const previous = currentRecord
  setCurrentRecord({ ...previous, ...data })   // optimistic
  try {
    const saved = await postToServer(data)
    setCurrentRecord(saved)                    // reconcile
  } catch {
    setCurrentRecord(previous)                 // rollback
    showToast('Save failed')
  }
}

Multi-step / wizard submission

For multi-step forms built with the Wizard component, validate each step before advancing and submit only the final step:
const handleStepChange = async (from: number, to: number) => {
  if (to > from) {
    const validation = await viewerRef.current?.formData.getValidationResult()
    if (validation && Object.keys(validation).length > 0) return false  // stay on step
  }
  return true
}

const handleFinalSubmit = async () => {
  await postToServer(viewerRef.current?.formData.data)
}

Auto-save / draft pattern

Save partial data on every change via onFormDataChange, debounced:
import { debounce } from 'lodash'

const saveDraft = useMemo(
  () => debounce((data) => fetch('/api/draft', {
    method: 'PUT',
    body: JSON.stringify(data),
  }), 800),
  []
)

<FormViewer
  view={view}
  getForm={getForm}
  onFormDataChange={(formData) => saveDraft(formData.data)}
/>

File upload during submit

The Uploader component stores file metadata in form data. When the user hits submit, upload files first, then POST the form with file IDs:
const handleSubmit = async () => {
  const data = viewerRef.current?.formData.data
  const uploadedIds = await Promise.all(
    data.attachments.map((f) => uploadFile(f))
  )
  await postToServer({ ...data, attachments: uploadedIds })
}

Prevent double-submit

Guard against rapid-fire clicks by disabling the button while in-flight (shown in the loading state example above). For defense in depth, also return early if submitting is already true:
const handleSubmit = async () => {
  if (submitting) return
  setSubmitting(true)
  try { /* ... */ } finally { setSubmitting(false) }
}

Common pitfalls

  • Calling onSubmit before validation resolvesgetValidationResult() is async; always await it.
  • Using formData.errors instead of validation resulterrors only reflects displayed errors. getValidationResult() returns the complete validation state, including fields the user hasn’t touched.
  • Submitting stale closure data — read viewerRef.current?.formData.data inside the handler, not from a state captured earlier.
  • Not handling network errors — always wrap fetch in try/catch; network errors don’t throw from response.ok === false.
Last modified on April 16, 2026