Form Builder
Type-safe reactive forms powered by Effect Schema and @effect/atom-react.
FormBuilder is a type-safe reactive form utility built on top of Effect Schema and @effect/atom-react.
It provides:
- Schema-driven form value inference.
- Field-level validation.
- Reactive form state.
- Typed field helpers for arrays.
- Submission handling for values, Promises, and Effects.
- Typed success and error callbacks.
- Tagged-error matching through
error.match(...).
Installation
CLI
npx shadcn add https://ui.tiesen.id.vn/r/form-builder.jsonManual
Install the required dependencies:
npm install effect@rc @effect/atom-react@rcCopy and paste the following code into your project:
import type { StandardSchemaV1 } from 'effect/StandardSchema'import { make, useAtomSet, useAtomSubscribe, useAtomValue,} from '@effect/atom-react'import * as Effect from 'effect/Effect'import * as Match from 'effect/Match'import * as Schema from 'effect/Schema'import * as SchemaIssue from 'effect/SchemaIssue'import * as Atom from 'effect/unstable/reactivity/Atom'import * as React from 'react'const EMPTY_ERRORS: readonly StandardSchemaV1.Issue[] = []export class FormBuilder< TFields extends Record<string, Schema.ConstraintDecoder<unknown, never>>,> { private _fields: TFields = {} as TFields private _refinements: FormBuilder.Refinement<TFields>[] = [] private _formatter: SchemaIssue.Formatter<StandardSchemaV1.FailureResult> // oxlint-disable-next-line class-methods-use-this public make<TValues extends Schema.Struct<TFields>['Type']>() { // oxlint-disable-next-line typescript/no-this-alias unicorn/no-this-assignment const self = this const valuesAtom = Atom.family((_key: keyof TValues) => Atom.make(undefined as TValues[keyof TValues]) ) const errorsAtom = Atom.family((_key: keyof TValues) => Atom.make(EMPTY_ERRORS as StandardSchemaV1.Issue[]) ) const isPendingAtom = Atom.make(false) const formAtom = make((props: { formId: string; defaultValues: TValues }) => Atom.writable( (get) => { const keys = Object.keys(props.defaultValues) as (keyof TValues)[] const values = { ...props.defaultValues } const errors = {} as Record<keyof TValues, StandardSchemaV1.Issue[]> for (const key of keys) { const val = get(valuesAtom(key)) if (val !== undefined) values[key] = val const err = get(errorsAtom(key)) if (err !== undefined) errors[key] = err } const isPending = get(isPendingAtom) return { formId: props.formId, values, errors, isPending } }, (ctx, newState: FormBuilder.State<TValues>) => { const keys = Object.keys(newState.values) as (keyof TValues)[] for (const key of keys) { const oldVal = ctx.get(valuesAtom(key)) const newVal = newState.values[key] if (oldVal !== newVal) ctx.set(valuesAtom(key), newVal) const oldErr = ctx.get(errorsAtom(key)) const newErr = newState.errors[key] if (oldErr !== newErr) ctx.set(errorsAtom(key), newErr) const oldPending = ctx.get(isPendingAtom) const newPending = newState.isPending if (oldPending !== newPending) ctx.set(isPendingAtom, newPending) } } ) ) // oxlint-disable-next-line unicorn/consistent-function-scoping function Provider({ defaultValues, children, }: Readonly<{ defaultValues: TValues children: React.ReactNode }>) { const formId = React.useId() const memoizedValue = React.useMemo( () => ({ formId, defaultValues }), [formId, defaultValues] ) return ( <formAtom.Provider value={memoizedValue}>{children}</formAtom.Provider> ) } function Field< TField extends keyof TValues, THelper extends (TValues[TField] extends readonly (infer U)[] ? { add: (item: U) => void update: (index: number, item: U) => void remove: (index: number) => void handleChange: (newValue: TValues[TField]) => void } : { handleChange: (newValue: TValues[TField]) => void }), >(props: { name: TField render: (props: { field: { value: TValues[TField] onBlur: () => void // A11y attributes id: string form: string name: string 'aria-describedby': string 'aria-invalid': boolean } meta: { descriptionId: string errorId: string errors: StandardSchemaV1.Issue[] isPending: boolean } helpers: THelper }) => React.ReactNode }) { const { name, render } = props const form = formAtom.use() const formId = useAtomValue(form, (s) => s.formId) const value = useAtomValue(form, (s) => s.values[name]) const errors = useAtomValue(form, (s) => s.errors[name] ?? []) const isPending = useAtomValue(form, (s) => s.isPending) const set = useAtomSet(form) const currentValueRef = React.useRef(value) const validator = React.useMemo( () => Schema.decodeUnknownResult(self._fields[name as keyof TFields], { errors: 'all', }), [name] ) const handleBlur = React.useCallback(() => { if (currentValueRef.current === value) return const result = validator(value) if (result._tag === 'Failure') { const { issues } = self._formatter(result.failure.issue) set((prev) => ({ ...prev, errors: { ...prev.errors, [name]: issues }, })) } currentValueRef.current = value }, [name, set, validator, value]) const id = `${formId}-${String(name)}` const descriptionId = `${id}-description` const errorId = `${id}-error` const field = React.useMemo( () => ({ value, onBlur: handleBlur, id, form: formId, name: String(name), 'aria-describedby': errors.length ? `${descriptionId} ${errorId}` : descriptionId, 'aria-invalid': errors.length > 0, }), [ descriptionId, errorId, errors.length, formId, handleBlur, id, name, value, ] ) const meta = React.useMemo( () => ({ descriptionId, errorId, errors, isPending }), [descriptionId, errorId, errors, isPending] ) const handleChange = React.useCallback( (newValue: TValues[TField]) => set((prev) => ({ ...prev, values: { ...prev.values, [name]: newValue }, errors: { ...prev.errors, [name]: EMPTY_ERRORS }, })), [name, set] ) const add = React.useCallback( (item: TValues[keyof TValues]) => Array.isArray(value) && set((prev) => { const array = prev.values[name] as unknown as unknown[] return { ...prev, values: { ...prev.values, [name]: [...array, item] }, errors: { ...prev.errors, [name]: EMPTY_ERRORS }, } }), [set, name, value] ) const update = React.useCallback( (index: number, item: TValues[keyof TValues]) => Array.isArray(value) && set((prev) => { const array = prev.values[name] as unknown as unknown[] const newArray = [...array] newArray[index] = item return { ...prev, values: { ...prev.values, [name]: newArray }, errors: { ...prev.errors, [name]: EMPTY_ERRORS }, } }), [set, name, value] ) const remove = React.useCallback( (index: number) => Array.isArray(value) && set((prev) => { const array = prev.values[name] as unknown as unknown[] const newArray = [...array] newArray.splice(index, 1) return { ...prev, values: { ...prev.values, [name]: newArray } } }), [set, name, value] ) const helpers = React.useMemo( () => Array.isArray(value) ? { add, update, remove, handleChange } : { handleChange }, [value, add, update, remove, handleChange] ) as THelper return render({ field, meta, helpers }) } const useSubmit = <TError = Error, TData = unknown>( onSubmit: ( values: TValues ) => Effect.Effect<TData, TError> | Promise<TData> | TData, options: { onSuccess?: (data: NoInfer<TData>) => Promise<unknown> | unknown onError?: ( error: NoInfer<TError> & { match: (handlers: FormBuilder.ExtractTaggedUnion<TError>) => void } ) => Promise<unknown> | unknown } = {} ) => { const form = formAtom.use() const set = useAtomSet(form) const valuesRef = React.useRef({} as TValues) useAtomSubscribe(form, (state) => (valuesRef.current = state.values), { immediate: true, }) const validator = React.useMemo(() => { let schema = Schema.Struct(this._fields) for (const { refinement, options: _options } of this._refinements) schema = schema.check( Schema.makeFilter((values) => refinement(values) ? undefined : _options ) ) return Schema.decodeUnknownResult(schema, { errors: 'all' }) }, []) return React.useCallback( async (event?: React.SubmitEvent) => { if (event) event.preventDefault() set((prev) => ({ ...prev, isPending: true })) try { const parsedValue = validator(valuesRef.current) if (parsedValue._tag === 'Failure') { const { issues } = this._formatter(parsedValue.failure.issue) return set((prev) => { const newErrors = { ...prev.errors } for (const issue of issues) { const [path] = issue.path as [keyof TValues] newErrors[path] = [...(newErrors[path] ?? []), issue] } return { ...prev, errors: newErrors, isPending: false } }) } set((prev) => ({ ...prev, errors: {} as Record<keyof TValues, StandardSchemaV1.Issue[]>, })) const result = await onSubmit(parsedValue.success as never) if (Effect.isEffect(result)) await Effect.runPromise( result.pipe( Effect.tap((data) => Effect.sync(() => options.onSuccess?.(data)) ), Effect.catch((error) => Effect.sync(() => { if (!options.onError) return options.onError(self.createMatchableError<TError>(error)) }) ) ) ) else options.onSuccess?.(result as TData) } catch (error) { options.onError?.(self.createMatchableError(error as TError)) } finally { set((prev) => ({ ...prev, isPending: false })) } }, [set, validator, onSubmit, options] ) } function useValue<TSelected>( selector: (state: FormBuilder.State<TValues>) => TSelected ): TSelected { const form = formAtom.use() return useAtomValue(form, (state) => selector(state)) } return { use: formAtom.use, useValue, useSubmit, Provider, Field, } } private constructor() { this._fields = {} as TFields this._refinements = [] this._formatter = SchemaIssue.makeFormatterStandardSchemaV1() } // oxlint-disable-next-line typescript/ban-types typescript/no-empty-object-type public static get empty(): FormBuilder<{}> { return new FormBuilder() } public add<TField extends string, TSchema extends Schema.Constraint>( field: TField, schema: TSchema ): FormBuilder<TFields & Record<TField, TSchema>> { this._fields = { ...this._fields, [field]: schema } return this as never } public refine<TRefinement extends FormBuilder.Refinement<TFields>>( refinement: TRefinement['refinement'], options: TRefinement['options'] ): this { this._refinements = [...this._refinements, { refinement, options }] return this } // oxlint-disable-next-line class-methods-use-this private createMatchableError<E>(error: E): E & { match: (handlers: FormBuilder.ExtractTaggedUnion<E>) => void } { return Object.assign(error as object, { match: (handlers: FormBuilder.ExtractTaggedUnion<E>) => Match.value(error).pipe( Match.tags(handlers as never), Match.exhaustive as never ), }) as never }}export namespace FormBuilder { export interface Refinement< TFields extends Record<string, Schema.Constraint>, > { refinement: (fields: Schema.Struct<TFields>['Type']) => boolean options: { path: (keyof TFields)[] issue: string } } export interface State<TValues> { formId: string values: TValues errors: Record<keyof TValues, StandardSchemaV1.Issue[]> isPending: boolean } type UnionToIntersection<U> = ( U extends unknown ? (k: U) => void : never ) extends (k: infer I) => void ? I : never export type ExtractTaggedUnion<T> = UnionToIntersection< T extends { readonly _tag: infer Tag extends string | number | symbol } ? Partial<{ [K in Tag]: (error: Extract<T, { readonly _tag: K }>) => void }> : never >}Create a form
Start with FormBuilder.empty and add fields with .add().
import * as Schema from 'effect/Schema'
const loginForm = FormBuilder.empty
.add(
'email',
Schema.String.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/u))
)
.add('password', Schema.String.check(Schema.isMinLength(8)))
.make()The form value type is inferred from the schemas:
{
email: string
password: string
}No separate TypeScript interface is required.
Provider
Every form instance exposes a Provider.
Wrap the form fields with the provider and supply defaultValues:
<loginForm.Provider
defaultValues={{
email: '',
password: '',
}}
>
{/* form */}
</loginForm.Provider>defaultValues is type-safe and must match the inferred form value type.
Fields
Use form.Field to render an individual field.
<loginForm.Field
name='email'
render={({ field, meta, helpers: { handleChange } }) => (
<Field data-invalid={meta.errors.length > 0}>
<FieldLabel htmlFor={field.id}>Email</FieldLabel>
<Input
{...field}
type='email'
onChange={(event) => handleChange(event.target.value)}
/>
<FieldDescription id={meta.descriptionId}>
Please enter your email address.
</FieldDescription>
<FieldError id={meta.errorId} errors={meta.errors} />
</Field>
)}
/>field
The field object contains the current value and accessibility attributes:
{
value: TField
onBlur: () => void
id: string
form: string
name: string
'aria-describedby': string
'aria-invalid': boolean
}meta
The meta object contains validation information and submission state:
{
descriptionId: string
errorId: string
errors: StandardSchemaV1.Issue[]
isPending: boolean
}errors contains the formatted validation issues for the field.
isPending is true while a submission is running.
helpers
For a normal field, helpers contains:
{
handleChange: (value: TField) => void
}For an array field, helpers contains:
{
add: (item: Item) => void
update: (index: number, item: Item) => void
remove: (index: number) => void
}The item type is inferred from the array schema.
Array fields
Define an array field with Schema.Array:
const form = FormBuilder.empty.add('tags', Schema.Array(Schema.String)).make()Then use the array helpers:
<form.Field
name='tags'
render={({ field, helpers: { add, update, remove } }) => (
<>
{field.value.map((tag, index) => (
<div key={index}>
<Input
value={tag}
onChange={(event) => update(index, event.target.value)}
/>
<Button type='button' onClick={() => remove(index)}>
Remove
</Button>
</div>
))}
<Button type='button' onClick={() => add('')}>
Add tag
</Button>
</>
)}
/>The helper argument is inferred from the array item type.
Access form state
The form returned by .make() exposes use() and useValue.
const form = loginForm.use()The state has the following shape:
{
formId: string
values: TValues
errors: Record<keyof TValues, StandardSchemaV1.Issue[]>
isPending: boolean
}Because the form uses Atom internally, consumers can subscribe to individual pieces of state:
const isPending = useAtomValue(form, (state) => state.isPending)
// or
const isPending = loginForm.useValue((state) => state.isPending)This allows components to subscribe to only the state they need.
Submit
Use useSubmit() to create a submit handler.
The callback receives the fully inferred form values:
const handleSubmit = loginForm.useSubmit((values) => {
console.log(values.email)
console.log(values.password)
return values
})The submit function can return:
- A plain value.
- A
Promise. - An
Effect.
Plain value
const handleSubmit = loginForm.useSubmit((values) => values)Promise
const handleSubmit = loginForm.useSubmit(async (values) => {
return await saveUser(values)
})Effect
const handleSubmit = loginForm.useSubmit(
Effect.fn(function* (values) {
yield* Effect.sleep(1000)
return yield* saveUser(values)
})
)For a native form:
<form onSubmit={handleSubmit}>{/* fields */}</form>The submit handler prevents the browser's default submission and validates the complete form before running onSubmit.
Submission validation
The complete form is validated before onSubmit runs.
If validation fails:
- The submission callback is not executed.
- Validation issues are formatted as
StandardSchemaV1.Issue. - Issues are stored against their corresponding field.
isPendingis reset tofalse.
Field validation also runs when a changed field loses focus.
Success handling
Use onSuccess to handle the successful result:
const handleSubmit = loginForm.useSubmit(
Effect.fn(function* (values) {
return yield* Effect.succeed(values)
}),
{
onSuccess: (values) => {
console.log('Form submitted successfully:', values)
},
}
)The values type is inferred from the submit result.
Tagged errors
Effect errors with an _tag can be handled with error.match(...).
Define a tagged error:
class FormError extends Schema.TaggedError<FormError>()('FormError', {
message: Schema.String,
}) {}Use it in an Effect:
const handleSubmit = loginForm.useSubmit(
Effect.fn(function* (values) {
if (/* failed */) {
return yield* Effect.fail(
new FormError({
message: 'Form validation failed',
})
)
}
return yield* Effect.succeed(values)
}),
{
onError: (error) => {
error.match({
FormError: (error) => {
console.error(error.message)
},
})
},
}
)match is only exposed when the error type is a tagged error.
For a normal Error, the callback remains a normal error:
loginForm.useSubmit(
() => {
throw new Error('Something went wrong')
},
{
onError: (error) => {
console.error(error.message)
// error.match does not exist here.
},
}
)This keeps the error API conditional instead of adding .match() to every error type.
Match multiple error types
When the error type is a union of tagged errors, match provides a handler for each tag:
class FormError extends Schema.TaggedError<FormError>()('FormError', {
message: Schema.String,
}) {}
class FormSubmissionError extends Schema.TaggedError<FormSubmissionError>()(
'FormSubmissionError',
{
message: Schema.String,
}
) {}Then:
const handleSubmit = loginForm.useSubmit<FormError | FormSubmissionError>(
Effect.fn(function* (values) {
// ...
return yield* Effect.succeed(values)
}),
{
onError: (error) => {
error.match({
FormError: (error) => {
console.error(error.message)
},
FormSubmissionError: (error) => {
console.error(error.message)
},
})
},
}
)The handler parameter is narrowed to the corresponding tagged error.
Pending state
isPending becomes true when submission starts and returns to false when submission finishes.
const isPending = loginForm.useValue((state) => state.isPending)It can be used to disable the form:
<form onSubmit={handleSubmit}>
<FieldSet disabled={isPending}>{/* fields */}</FieldSet>
</form>The same state is available from Field metadata:
<form.Field
name='email'
render={({ meta }) => <Input disabled={meta.isPending} />}
/>API reference
FormBuilder.empty
Creates an empty FormBuilder.
const form = FormBuilder.empty.add(field, schema)
Adds a field and extends the inferred form value type.
const form = FormBuilder.empty
.add('name', Schema.String)
.add('age', Schema.Number).refine(refinement, options)
Registers a form-level refinement.
form.refine((fields) => fields.password === fields.confirmPassword, {
path: ['confirmPassword'],
issue: 'Passwords do not match',
})The refinement receives the complete typed form value.
.make()
Creates the runtime form API:
const form = FormBuilder.empty.add('email', Schema.String).make()The returned object exposes:
{
Provider
Field
use
useSubmit
}<Provider>
Creates the form state scope.
<form.Provider defaultValues={...}>
...
</form.Provider><Field>
Renders and subscribes to a field.
<form.Field
name="email"
render={({ field, meta, helpers }) => ...}
/>use()
Returns the form Atom.
const form = loginForm.use()useValue(selector)
Returns a selected piece of form state.
const isPending = loginForm.useValue((state) => state)useSubmit(onSubmit, options?)
Creates a typed submit handler.
const handleSubmit = loginForm.useSubmit(onSubmit, {
onSuccess,
onError,
})Design notes
The builder derives its form value type from the Effect schemas passed to .add().
The runtime state is backed by Atom families for individual field values and errors, while isPending is maintained as form-level state.
Field components subscribe to their own value and errors, allowing the form to remain reactive without requiring every field to consume the complete form state.