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