Lib

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.json

Manual

Install the required dependencies:

npm install effect@rc @effect/atom-react@rc

Copy 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:

  1. The submission callback is not executed.
  2. Validation issues are formatted as StandardSchemaV1.Issue.
  3. Issues are stored against their corresponding field.
  4. isPending is reset to false.

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.

On this page