Files
effect-view/packages/docs/docs/forms.md
T
Julien Valverdé 75f51cdf1c
Lint / lint (push) Failing after 42s
Overhaul form docs
2026-07-22 01:31:17 +02:00

9.2 KiB

sidebar_position, title
sidebar_position title
3 Forms

Forms

Effect View forms are built from an Effect Schema. The schema is the source of truth for the shape of the form, its validation rules, and the value your application receives. The form layer supplies reactive state and lifecycle; it does not replace your UI components.

A schema distinguishes the encoded value edited by the UI from the decoded value used by the application. For example, an <input> edits an age as a string, while the submit handler or application state receives a number:

import { Schema } from "effect"

const ProfileSchema = Schema.Struct({
  displayName: Schema.String.check(
    Schema.isMinLength(1, { message: "Enter a display name" }),
  ),
  age: Schema.NumberFromString,
  contact: Schema.Struct({
    email: Schema.String.check(
      Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
        message: "Enter a valid email address",
      }),
    ),
  }),
})

That schema gives the form several useful properties:

  • Validation stays next to the data definition instead of being duplicated in components and submit handlers.
  • Inputs can keep UI-friendly encoded values while business logic receives decoded, typed values.
  • Nested schema issue paths can be routed automatically to the matching subform or field.
  • Synchronous and effectful schema validation use the same form model.
  • The same schema can validate a local draft or protect writes to shared state.

There are two concrete root form implementations. Choose one based on where a valid decoded value should go:

Implementation Owns a local draft What happens to a valid value
MutationForm Yes It is passed to a mutation when submit runs.
LensForm Yes It is written automatically to a target Lens.

MutationForm: validate, then submit

Use MutationForm for registration, checkout, search, and other workflows with an explicit submit action. It owns the encoded draft, continuously decodes it with the schema, and exposes the last valid decoded value. Calling submit runs the mutation only when the form can commit.

import { Effect } from "effect"
import { Component, MutationForm, View } from "effect-view"

const CreateProfileView = Component.make("CreateProfile")(function* () {
  const form = yield* Component.useOnMount(() =>
    MutationForm.service({
      schema: ProfileSchema,
      initialEncodedValue: {
        displayName: "",
        age: "",
        contact: { email: "" },
      },
      f: ([profile]) =>
        Effect.log(
          `Creating ${profile.displayName}, age ${profile.age}`,
        ),
    }),
  )

  const [canCommit, isCommitting] = yield* View.useAll([
    form.canCommit,
    form.isCommitting,
  ])
  const runPromise = yield* Component.useRunPromise()

  // Focused inputs are added in "Focus into subforms" below.
  return (
    <button
      disabled={!canCommit || isCommitting}
      onClick={() => void runPromise(form.submit)}
    >
      {isCommitting ? "Creating…" : "Create profile"}
    </button>
  )
})

Notice that age starts as a string because it is an encoded input value. The mutation receives the schema's decoded profile, so profile.age is a number. Schema transformations happen before the mutation and schema issues prevent an invalid draft from being submitted.

MutationForm.service also starts initial validation in the current scope. Use it inside Component.useOnMount, a scoped service, or another Effect scope so its validation and mutation work is cleaned up with its owner.

LensForm: validate, then synchronize

Use LensForm for settings panels, inspectors, and edit screens where valid changes should update existing state without a final submit. Its target holds decoded application data; the form derives an encoded draft from that target and keeps the two synchronized in both directions.

import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, LensForm, View } from "effect-view"

const EditProfileView = Component.make("EditProfile")(function* () {
  const [form, profile] = yield* Component.useOnMount(() =>
    Effect.gen(function* () {
      const profile = Lens.fromSubscriptionRef(
        yield* SubscriptionRef.make({
          displayName: "Ada",
          age: 37,
          contact: { email: "ada@example.com" },
        }),
      )

      const form = yield* LensForm.service({
        schema: ProfileSchema,
        target: profile,
      })

      return [form, profile] as const
    }),
  )

  const [savedProfile, isCommitting] = yield* View.useAll([
    profile,
    form.isCommitting,
  ])

  // Focused inputs are added in "Focus into subforms" below.
  return (
    <output>
      {isCommitting
        ? "Saving…"
        : `${savedProfile.displayName} is ${savedProfile.age}`}
    </output>
  )
})

Here the target contains age: 37, while the form exposes the encoded value age: "37" to an input. A valid edit is decoded and written to profile. Invalid input remains in the form so the user can correct it, but it never reaches the target. If another part of the application updates the target, LensForm encodes that value back into the draft.

Pass initialEncodedValue only when the first draft should differ from the encoded target. Otherwise LensForm.service obtains the initial draft by encoding the target through the schema.

Focus into subforms

After creating either root implementation, focus it along the schema's structure. Focusing an object can produce a nested subform, not only a leaf field:

const displayNameField = Form.focusObjectOn(form, "displayName")
const ageField = Form.focusObjectOn(form, "age")

const contactForm = Form.focusObjectOn(form, "contact")
const emailField = Form.focusObjectOn(contactForm, "email")

contactForm can be passed to a component responsible for the whole contact section. emailField can be passed to a single input. Each focused form narrows all of the following to its path:

  • encodedValue: the UI-facing value that can be edited.
  • value: the decoded value as an Option.
  • issues: only schema issues at or below the focused path.

Commit state is intentionally shared with the root. A nested component can use isValidating, canCommit, and isCommitting without separately coordinating with its parent.

The focusing helpers mirror the kind of data being traversed:

const property = Form.focusObjectOn(form, "contact")
const item = Form.focusArrayAt(itemsForm, 0)
const tupleMember = Form.focusTupleAt(tupleForm, 1)
const chunkItem = Form.focusChunkAt(chunkForm, 0)

Because issue paths come from the schema, validation messages follow the same focus operation. A component receiving emailField does not need to search the root issue collection for contact.email errors.

Bind a subform to an input

Form.useInput connects a focused form to a controlled component. It returns a React-style { value, setValue } pair backed by the form's encodedValue Lens. An optional debounce delays schema validation while the user types.

import { Component, Form, View } from "effect-view"

const TextInputView = Component.make("TextInput")(
  function* (props: {
    readonly form: Form.Form<readonly PropertyKey[], unknown, string>
    readonly label: string
    readonly type?: "text" | "email" | "number"
  }) {
    const input = yield* Form.useInput(props.form, {
      debounce: "250 millis",
    })
    const [issues, isValidating, isCommitting] = yield* View.useAll([
      props.form.issues,
      props.form.isValidating,
      props.form.isCommitting,
    ])

    return (
      <label>
        {props.label}
        <input
          type={props.type}
          value={input.value}
          disabled={isCommitting}
          aria-invalid={issues.length > 0}
          onChange={(event) =>
            input.setValue(event.currentTarget.value)
          }
        />
        {isValidating && <small>Validating</small>}
        {issues.map((issue, index) => (
          <small key={index}>{issue.message}</small>
        ))}
      </label>
    )
  },
)

The same input component works with a field focused from a MutationForm or a LensForm; it only depends on the common Form interface.

Use Form.useOptionalInput when the encoded field is an Effect Option. It returns value and setValue together with enabled and setEnabled, making it suitable for an optional input that the user can toggle on and off.

The common Form model

Both root implementations and every focused subform implement Form.Form. They expose the schema pipeline as reactive Lenses and Views:

Member Meaning
encodedValue Writable input-shaped state.
value The decoded value as an Option; None until decoding succeeds.
issues Standard Schema issues scoped to this form's path.
isValidating Whether schema decoding is currently running.
canCommit Whether the root has a valid value and is ready to commit.
isCommitting Whether a mutation or target write is in progress.

The form model itself is Effect code. Effect View adds the React-facing hooks, including Form.useInput and Form.useOptionalInput, while your own component library remains responsible for rendering controls and layout.