Form
Components and hooks for handling forms with progressive enhancement.
Import
import {
Form,
FormProvider,
useFormContext,
useSubmit,
useFetcher,
useFetchers,
useActionData, // Form-specific
useNavigation, // Form-specific
serializeFormData,
parseFormData,
formDataToObject,
objectToFormData
} from '@ereo/client'
// Types
import type {
FormProps,
ActionResult,
SubmissionState,
SubmitOptions,
FetcherState,
Fetcher,
FormContextValue,
FormNavigationState
} from '@ereo/client'Types
// Result from a form action submission (client-side)
interface ActionResult<T = unknown> {
data?: T
error?: Error
status: number
ok: boolean
}
// Submission state for tracking form submissions
type SubmissionState = 'idle' | 'submitting' | 'loading' | 'error'
// Submit options for programmatic submission
interface SubmitOptions {
method?: 'get' | 'post' | 'put' | 'patch' | 'delete'
action?: string
replace?: boolean
preventScrollReset?: boolean
encType?: 'application/x-www-form-urlencoded' | 'multipart/form-data'
fetcherKey?: string
}Note on
ActionResult: TheActionResulttype in@ereo/clientrepresents the client-side response from a form submission — it hasdata,error,status, andokfields. This is different from theActionResulttype in@ereo/data, which represents a server-side action return value withsuccess,data, anderrors(a validation error map). The client-side version wraps the HTTP response; the server-side version is what your action handler returns.
Form
A form component with progressive enhancement. Works without JavaScript as a standard HTML form and enhances with client-side submission when JS is available.
Props
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'method' | 'action' | 'encType'> {
// HTTP method (default: 'post')
method?: 'get' | 'post' | 'put' | 'patch' | 'delete'
// Action URL (default: current route)
action?: string
// Called when submission starts
onSubmitStart?: () => void
// Called when submission completes
onSubmitEnd?: (result: ActionResult) => void
// Replace history instead of push
replace?: boolean
// Prevent scroll reset after navigation
preventScrollReset?: boolean
// Encoding type
encType?: 'application/x-www-form-urlencoded' | 'multipart/form-data'
// Fetcher key for non-navigation submissions
fetcherKey?: string
// Form children
children?: ReactNode
}Basic Usage
import { Form } from '@ereo/client'
export default function NewPost() {
return (
<Form method="post">
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</Form>
)
}With Action URL
<Form method="post" action="/api/subscribe">
<input name="email" type="email" />
<button type="submit">Subscribe</button>
</Form>With Callbacks
<Form
method="post"
onSubmitStart={() => {
console.log('Submission started')
}}
onSubmitEnd={(result) => {
if (result.ok) {
console.log('Success:', result.data)
toast.success('Saved!')
} else {
console.error('Error:', result.error)
toast.error('Failed to save')
}
}}
>
...
</Form>Delete Form
<Form method="delete" action={`/posts/${postId}`}>
<button type="submit">Delete Post</button>
</Form>useSubmit
Hook for programmatic form submission.
Signature
function useSubmit(): (
target: HTMLFormElement | FormData | URLSearchParams | Record<string, string>,
options?: SubmitOptions
) => Promise<ActionResult>The returned function submits form data and returns a Promise with the result.
Example
import { useSubmit } from '@ereo/client'
export default function SearchForm() {
const submit = useSubmit()
const handleSearch = (query: string) => {
submit(
{ q: query },
{ method: 'get', action: '/search' }
)
}
return (
<input
type="search"
onChange={(e) => handleSearch(e.target.value)}
/>
)
}Submit Form Reference
import { useRef, useEffect } from 'react'
import { useSubmit } from '@ereo/client'
import { Form } from '@ereo/client'
export default function AutoSaveForm() {
const formRef = useRef<HTMLFormElement>(null)
const submit = useSubmit()
useEffect(() => {
const interval = setInterval(() => {
if (formRef.current) {
submit(formRef.current)
}
}, 30000) // Auto-save every 30 seconds
return () => clearInterval(interval)
}, [submit])
return (
<Form ref={formRef} method="post">
<textarea name="content" />
</Form>
)
}useFetcher
Hook for non-navigation form submissions. Useful for inline updates that don't require page navigation.
Signature
function useFetcher<T = unknown>(key?: string): Fetcher<T>
interface FetcherState<T = unknown> {
state: SubmissionState
data?: T
error?: Error
formData?: FormData
formMethod?: string
formAction?: string
}
interface Fetcher<T = unknown> extends FetcherState<T> {
// Form component for the fetcher
Form: (props: Omit<FormProps, 'fetcherKey'>) => ReactElement
// Submit function for programmatic submission
submit: (
target: HTMLFormElement | FormData | URLSearchParams | Record<string, string>,
options?: SubmitOptions
) => Promise<void>
// Load data from a URL
load: (href: string) => Promise<void>
// Reset fetcher state
reset: () => void
}Example
import { useFetcher } from '@ereo/client'
function LikeButton({ postId, likes }: { postId: string; likes: number }) {
const fetcher = useFetcher<{ likes: number }>()
// Optimistic UI
const displayLikes = fetcher.data?.likes ?? likes
const isSubmitting = fetcher.state === 'submitting'
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button type="submit" disabled={isSubmitting}>
{displayLikes} Likes
</button>
</fetcher.Form>
)
}Loading Data
import { useFetcher } from '@ereo/client'
function UserCard({ userId }: { userId: string }) {
const fetcher = useFetcher<User>()
useEffect(() => {
fetcher.load(`/api/users/${userId}`)
}, [userId])
if (fetcher.state === 'loading') {
return <Skeleton />
}
if (fetcher.data) {
return <div>{fetcher.data.name}</div>
}
return null
}Multiple Fetchers
function PostActions({ postId }) {
const likeFetcher = useFetcher()
const bookmarkFetcher = useFetcher()
const shareFetcher = useFetcher()
return (
<div className="actions">
<likeFetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button>Like</button>
</likeFetcher.Form>
<bookmarkFetcher.Form method="post" action="/api/bookmark">
<input type="hidden" name="postId" value={postId} />
<button>Bookmark</button>
</bookmarkFetcher.Form>
<shareFetcher.Form method="post" action="/api/share">
<input type="hidden" name="postId" value={postId} />
<button>Share</button>
</shareFetcher.Form>
</div>
)
}useFetchers
Returns all active fetcher states. Useful for showing global loading indicators or tracking multiple in-flight submissions.
Signature
function useFetchers(): FetcherState[]Example
import { useFetchers } from '@ereo/client'
function GlobalProgress() {
const fetchers = useFetchers()
const isAnySubmitting = fetchers.some(f => f.state === 'submitting')
if (!isAnySubmitting) return null
return <div className="progress-bar" />
}FormProvider / useFormContext
Share form state across components.
FormContextValue
interface FormContextValue {
// Current action data from the last submission
actionData: unknown
// Current submission state
state: SubmissionState
// Update action data
setActionData: (data: unknown) => void
// Update submission state
setState: (state: SubmissionState) => void
}Example
import { FormProvider, useFormContext, Form } from '@ereo/client'
function FormFields() {
const context = useFormContext()
const isSubmitting = context?.state === 'submitting'
return (
<>
<input name="email" disabled={isSubmitting} />
</>
)
}
function SubmitButton() {
const context = useFormContext()
const isSubmitting = context?.state === 'submitting'
return (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
)
}
export default function MyForm() {
return (
<FormProvider>
<Form method="post">
<FormFields />
<SubmitButton />
</Form>
</FormProvider>
)
}Utility Functions
serializeFormData
Serialize FormData to a URL-encoded string.
function serializeFormData(formData: FormData): stringconst formData = new FormData()
formData.append('name', 'John')
formData.append('email', 'john@example.com')
const serialized = serializeFormData(formData)
// 'name=John&email=john%40example.com'parseFormData
Parse a URL-encoded string to FormData.
function parseFormData(data: string): FormDataconst formData = parseFormData('name=John&email=john%40example.com')
// FormData with name and email entriesNote: This is the client-side
parseFormDatafrom@ereo/client. It takes a URL-encoded string and returns aFormDataobject. The@ereo/datapackage has a differentparseFormDatafunction designed for server-side use — it accepts aRequestobject and auto-detects the content type (JSON, FormData, or text). Make sure you import from the correct package for your use case.
formDataToObject
Convert FormData to a plain object. Handles multiple values for the same key by creating arrays.
function formDataToObject(formData: FormData): Record<string, string | string[]>const formData = new FormData()
formData.append('name', 'John')
formData.append('tags', 'react')
formData.append('tags', 'typescript')
const obj = formDataToObject(formData)
// { name: 'John', tags: ['react', 'typescript'] }Note: This is the client-side
formDataToObjectfrom@ereo/client. It performs a simple flat conversion. The@ereo/datapackage has a more advancedformDataToObjectthat supports TypeScript generics, type coercion, and nested object parsing (e.g.,user.name→{ user: { name: ... } }). Use the@ereo/dataversion on the server when you need nested structures or type coercion; use this client version for straightforward flat conversions.
objectToFormData
Convert a plain object to FormData. Handles arrays by appending multiple values.
function objectToFormData(obj: Record<string, string | string[] | number | boolean>): FormDataconst formData = objectToFormData({
name: 'John',
tags: ['react', 'typescript'],
count: 5,
active: true
})
// FormData with name, tags (twice), count, active entriesPatterns
Confirmation Dialog
function DeleteButton({ postId }) {
const [confirm, setConfirm] = useState(false)
const fetcher = useFetcher()
if (confirm) {
return (
<div>
<p>Are you sure?</p>
<fetcher.Form method="delete" action={`/posts/${postId}`}>
<button type="submit">Yes, delete</button>
</fetcher.Form>
<button onClick={() => setConfirm(false)}>Cancel</button>
</div>
)
}
return <button onClick={() => setConfirm(true)}>Delete</button>
}File Upload
<Form method="post" encType="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Upload</button>
</Form>Debounced Search
import { useState, useEffect } from 'react'
import { useSubmit } from '@ereo/client'
function SearchInput() {
const submit = useSubmit()
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => {
if (query) {
submit({ q: query }, { method: 'get', action: '/search' })
}
}, 300)
return () => clearTimeout(timeout)
}, [query, submit])
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
)
}