Add touched, async-safe validation state and accessible error props to any React control without changing UI libraries.
use-bollard-field-demo.tsx51 lines
"use client"
import * as React from "react"
import { useBollardField } from "@/components/ui/use-bollard-field"
const fieldClass =
"h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40 aria-invalid:border-destructive"
export function Demo() {
const email = useBollardField({
initialValue: "priya@acme",
validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? undefined : "Enter a complete email address, such as priya@example.com.",
})
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
await email.validate()
}
return (
<form noValidate onSubmit={submit} className="w-full max-w-md space-y-3 rounded-lg border bg-card p-4 text-left">
<div className="space-y-1">
<label htmlFor="bollard-email-demo" className="text-sm font-medium">Work email</label>
<input
id="bollard-email-demo"
type="email"
autoComplete="email"
value={email.value}
onChange={(event) => email.setValue(event.target.value)}
onBlur={email.onBlur}
{...email.ariaProps}
className={fieldClass}
/>
</div>
<div className="flex items-center justify-between gap-3">
<p id={email.errorId} role={email.invalid ? "alert" : undefined} className="min-h-5 text-xs text-destructive">
{email.error}
</p>
<button
type="submit"
disabled={email.isValidating}
className="h-8 shrink-0 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-wait disabled:opacity-60"
>
{email.isValidating ? "Checking…" : "Continue"}
</button>
</div>
<p className="border-t pt-3 text-xs text-muted-foreground">The input stays yours. Bollard supplies field state and accessible error props.</p>
</form>
)
}Installation
The files land in your project. They're yours to edit.
Copy and paste the following code into
components/ui121 lines"use client" // Bollard UI — use-bollard-field · MIT licence · https://bollard-ui.vercel.app/components/use-bollard-field import * as React from "react" import { createValidationRunner, getBollardAriaProps, type BollardValidator } from "./validation" export { runBollardValidation } from "./validation" export { getBollardAriaProps } from "./validation" export type { BollardValidator } from "./validation" export interface UseBollardFieldOptions<T> { /** The field's initial value. */ initialValue: T /** Your validation logic. Sync functions and async checks are both supported. */ validate: BollardValidator<T> } export interface BollardField<T> { value: T /** Connect this to a control's onValueChange, onChange, or equivalent callback. */ setValue: (value: T) => void /** Validate on blur and reveal the inline error. */ onBlur: () => void error: string | undefined invalid: boolean isValidating: boolean errorId: string /** Spread onto the control to connect its accessible error state. */ ariaProps: { "aria-invalid": true | undefined; "aria-describedby": string | undefined } /** Merge existing hint IDs with Bollard's error ID: `ariaPropsFor(hintId)`. */ ariaPropsFor: (describedBy?: string) => { "aria-invalid": true | undefined; "aria-describedby": string | undefined } /** Set a server-side error; it clears when the user edits the value. */ setError: (message: string) => void clearError: () => void /** Restore the initial value, or provide a new one. */ reset: (value?: T) => void /** Run validation immediately. Resolves true if valid. */ validate: () => Promise<boolean> } /** * Validation state for any React control. Bollard UI controls, shadcn/ui * controls and custom controls can all connect through `value`, `setValue` * and `ariaProps`; this hook renders no UI and has no styling dependency. */ export function useBollardField<T>({ initialValue, validate }: UseBollardFieldOptions<T>): BollardField<T> { const [value, setValueState] = React.useState(initialValue) const [error, setErrorState] = React.useState<string>() const [touched, setTouched] = React.useState(false) const [isValidating, setIsValidating] = React.useState(false) const valueRef = React.useRef(value) const [runner] = React.useState(() => createValidationRunner(validate, (state) => { if (state.isValidating !== undefined) setIsValidating(state.isValidating) if ("error" in state) setErrorState(state.error) })) const id = React.useId() const errorId = `bollard-${id.replace(/:/g, "")}-error` React.useEffect(() => { runner.setValidator(validate) }, [runner, validate]) React.useEffect(() => { valueRef.current = value }, [value]) const setValue = React.useCallback((nextValue: T) => { runner.invalidate() valueRef.current = nextValue setValueState(nextValue) // A server/previous error shouldn't stick after the user changes the value. setErrorState(undefined) if (touched) void runner.validate(nextValue) }, [runner, touched]) const onBlur = React.useCallback(() => { setTouched(true) void runner.validate(valueRef.current) }, [runner]) const setError = React.useCallback((message: string) => { runner.invalidate() setIsValidating(false) setErrorState(message) }, [runner]) const clearError = React.useCallback(() => { runner.invalidate() setIsValidating(false) setErrorState(undefined) }, [runner]) const reset = React.useCallback((nextValue: T = initialValue) => { runner.invalidate() valueRef.current = nextValue setValueState(nextValue) setErrorState(undefined) setTouched(false) setIsValidating(false) }, [initialValue, runner]) const invalid = Boolean(error) return { value, setValue, onBlur, error, invalid, isValidating, errorId, ariaProps: getBollardAriaProps(errorId, error), ariaPropsFor: (describedBy) => getBollardAriaProps(errorId, error, describedBy), setError, clearError, reset, validate: async () => !(await runner.validate(valueRef.current)), } }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
What it catches
- Validation coupled to a specific input component or UI library
- Async responses overwriting the result for a newer value
- Inline errors not connected to controls with aria-describedby
- Server errors that remain visible after the user edits the field
Accessibility
- ariaProps connects the control to the error ID and marks invalid state
- Render the error with id={field.errorId} and role=alert or aria-live=polite
- No focus movement or UI is imposed; the consuming form chooses its summary behavior
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- You provide the validator and render the error message yourself
- This hook manages one field; it does not replace form submission or schema validation
- Validation runs on blur, then again after edits; call validate() for submit-time checks
Dependencies
None beyond React and Tailwind CSS.