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.
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" } }
}
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 resolves — getValidationResult() is async; always await it.
- Using
formData.errors instead of validation result — errors 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