diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md
index 47d5dad..a90c999 100644
--- a/packages/docs/docs/forms.md
+++ b/packages/docs/docs/forms.md
@@ -5,64 +5,242 @@ title: Forms
# Forms
-The form modules provide an Effect-first model for editable, schema-validated
-state. They are not a visual component library. Instead, they give you
-Lenses/Views for form values, validation issues, and commit state, so
-you can build the UI with whatever components your app already uses.
+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.
-The base `Form` type represents a field. A field tracks both the raw encoded
-value used by inputs and the decoded value produced by an Effect `Schema`:
+A schema distinguishes the **encoded value** edited by the UI from the
+**decoded value** used by the application. For example, an `` edits an
+age as a string, while the submit handler or application state receives a
+number:
-- `encodedValue`: a `Lens` containing the raw input value.
-- `value`: a `View` containing the decoded value as an `Option`.
-- `issues`: a `View` containing schema validation issues for that field.
-- `canCommit`, `isValidating`, and `isCommitting`: `View` flags for UI
- state.
+```tsx
+import { Schema } from "effect"
-There are two root form flavors:
+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",
+ }),
+ ),
+ }),
+})
+```
-- `MutationForm`: owns local encoded form state, validates it, and submits a
- decoded value through a mutation.
-- `LensForm`: wraps an existing target `Lens` and writes valid changes
- back to that target.
+That schema gives the form several useful properties:
-Most apps create one of those root forms, then focus it into fields with
-`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
+- 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.
-The core form model is Effect code and can technically be used outside
-Effect View. Effect View adds the React-facing hooks, such as `Form.useInput`, that
-make those forms convenient to bind to components.
+There are two concrete root form implementations. Choose one based on where a
+valid decoded value should go:
-## Field Inputs
+| 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`. |
-Use `Form.useInput` to connect a `Form` field to a controlled input. It returns
-a React-style `{ value, setValue }` pair and writes changes back to the field's
-`encodedValue` 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.
+
+```tsx
+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 (
+
+ )
+})
+```
+
+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.
+
+```tsx
+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 (
+
+ )
+})
+```
+
+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:
+
+```tsx
+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:
+
+```tsx
+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.
```tsx
import { Component, Form, View } from "effect-view"
const TextInputView = Component.make("TextInput")(
function* (props: {
- readonly form: Form.Form
+ readonly form: Form.Form
readonly label: string
+ readonly type?: "text" | "email" | "number"
}) {
const input = yield* Form.useInput(props.form, {
debounce: "250 millis",
})
- const [issues] = yield* View.useAll([props.form.issues])
+ const [issues, isValidating, isCommitting] = yield* View.useAll([
+ props.form.issues,
+ props.form.isValidating,
+ props.form.isCommitting,
+ ])
return (
)
@@ -70,93 +248,27 @@ const TextInputView = Component.make("TextInput")(
)
```
-Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter
-pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
-can be toggled on and off.
+The same input component works with a field focused from a `MutationForm` or a
+`LensForm`; it only depends on the common `Form` interface.
-## Mutation Forms
+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.
-Use `MutationForm` when the user edits local encoded form state, validates it
-with a schema, and then submits a decoded value.
+## The common Form model
-```tsx
-import { Effect, Schema } from "effect"
-import { Component, Form, MutationForm, View } from "effect-view"
-import { TextInputView } from "./TextInputView"
+Both root implementations and every focused subform implement `Form.Form`.
+They expose the schema pipeline as reactive Lenses and Views:
-const RegisterSchema = Schema.Struct({
- email: Schema.String,
- password: Schema.String,
-})
+| 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. |
-const RegisterFormView = Component.make("RegisterForm")(
- function* () {
- const [form, email, password] = yield* Component.useOnMount(() =>
- Effect.gen(function* () {
- const form = yield* MutationForm.service({
- schema: RegisterSchema,
- initialEncodedValue: {
- email: "",
- password: "",
- },
- f: ([value]) => Effect.log(`Registering ${value.email}`),
- })
-
- return [
- form,
- Form.focusObjectOn(form, "email"),
- Form.focusObjectOn(form, "password"),
- ] as const
- }),
- )
- const [canCommit, isCommitting] = yield* View.useAll([
- form.canCommit,
- form.isCommitting,
- ])
- const runPromise = yield* Component.useRunPromise()
- const TextInput = yield* TextInputView.use
-
- return (
-
- )
- },
-)
-```
-
-`MutationForm.service` starts validation in the form's scope. The form keeps
-its encoded input state, decoded value, validation issues, submit mutation, and
-commit flags available as Lenses/Views.
-
-## Lens Forms
-
-Use `LensForm` when a form should edit an existing target `Lens`.
-Instead of submitting a final value, valid encoded changes are synchronized back
-to the target Lens.
-
-This is useful for edit screens where the form is a validated view over existing
-state. Use `MutationForm` for "submit this draft" flows, and `LensForm` for
-"keep this target state synchronized when valid" flows.
-
-## Focusing Fields
-
-Forms can be focused just like Lenses:
-
-```tsx
-const emailField = Form.focusObjectOn(form, "email")
-const firstItemField = Form.focusArrayAt(form, 0)
-```
-
-Focused fields keep the parent form's validation and commit state, but narrow
-`encodedValue`, `value`, and `issues` to the field path. This lets each input
-component receive only the field it needs.
+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.