Overhaul form docs
Lint / lint (push) Failing after 42s

This commit is contained in:
Julien Valverdé
2026-07-22 01:31:17 +02:00
parent d7f0aa1a38
commit 75f51cdf1c
+228 -116
View File
@@ -5,64 +5,242 @@ title: Forms
# Forms # Forms
The form modules provide an Effect-first model for editable, schema-validated Effect View forms are built from an Effect `Schema`. The schema is the source of
state. They are not a visual component library. Instead, they give you truth for the shape of the form, its validation rules, and the value your
Lenses/Views for form values, validation issues, and commit state, so application receives. The form layer supplies reactive state and lifecycle; it
you can build the UI with whatever components your app already uses. does not replace your UI components.
The base `Form` type represents a field. A field tracks both the raw encoded A schema distinguishes the **encoded value** edited by the UI from the
value used by inputs and the decoded value produced by an Effect `Schema`: **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:
- `encodedValue`: a `Lens` containing the raw input value. ```tsx
- `value`: a `View` containing the decoded value as an `Option`. import { Schema } from "effect"
- `issues`: a `View` containing schema validation issues for that field.
- `canCommit`, `isValidating`, and `isCommitting`: `View` flags for UI
state.
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 That schema gives the form several useful properties:
decoded value through a mutation.
- `LensForm`: wraps an existing target `Lens` and writes valid changes
back to that target.
Most apps create one of those root forms, then focus it into fields with - Validation stays next to the data definition instead of being duplicated in
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers. 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 There are two concrete root form implementations. Choose one based on where a
Effect View. Effect View adds the React-facing hooks, such as `Form.useInput`, that valid decoded value should go:
make those forms convenient to bind to components.
## 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 ## MutationForm: validate, then submit
a React-style `{ value, setValue }` pair and writes changes back to the field's
`encodedValue` Lens. 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 (
<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.
```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 (
<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:
```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 ```tsx
import { Component, Form, View } from "effect-view" import { Component, Form, View } from "effect-view"
const TextInputView = Component.make("TextInput")( const TextInputView = Component.make("TextInput")(
function* (props: { function* (props: {
readonly form: Form.Form<readonly PropertyKey[], string, string> readonly form: Form.Form<readonly PropertyKey[], unknown, string>
readonly label: string readonly label: string
readonly type?: "text" | "email" | "number"
}) { }) {
const input = yield* Form.useInput(props.form, { const input = yield* Form.useInput(props.form, {
debounce: "250 millis", 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 ( return (
<label> <label>
{props.label} {props.label}
<input <input
type={props.type}
value={input.value} value={input.value}
onChange={(event) => input.setValue(event.currentTarget.value)} disabled={isCommitting}
aria-invalid={issues.length > 0}
onChange={(event) =>
input.setValue(event.currentTarget.value)
}
/> />
{isValidating && <small>Validating</small>}
{issues.map((issue, index) => ( {issues.map((issue, index) => (
<small key={index}> <small key={index}>{issue.message}</small>
{issue.message}
</small>
))} ))}
</label> </label>
) )
@@ -70,93 +248,27 @@ const TextInputView = Component.make("TextInput")(
) )
``` ```
Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter The same input component works with a field focused from a `MutationForm` or a
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that `LensForm`; it only depends on the common `Form` interface.
can be toggled on and off.
## 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 ## The common Form model
with a schema, and then submits a decoded value.
```tsx Both root implementations and every focused subform implement `Form.Form`.
import { Effect, Schema } from "effect" They expose the schema pipeline as reactive Lenses and Views:
import { Component, Form, MutationForm, View } from "effect-view"
import { TextInputView } from "./TextInputView"
const RegisterSchema = Schema.Struct({ | Member | Meaning |
email: Schema.String, | --- | --- |
password: Schema.String, | `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")( The form model itself is Effect code. Effect View adds the React-facing hooks,
function* () { including `Form.useInput` and `Form.useOptionalInput`, while your own component
const [form, email, password] = yield* Component.useOnMount(() => library remains responsible for rendering controls and layout.
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 (
<form
onSubmit={(event) => {
event.preventDefault()
void runPromise(form.submit)
}}
>
<TextInput label="Email" form={email} />
<TextInput label="Password" form={password} />
<button disabled={!canCommit || isCommitting}>
{isCommitting ? "Submitting..." : "Submit"}
</button>
</form>
)
},
)
```
`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.