+228
-116
@@ -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 `<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.
|
||||
- `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 (
|
||||
<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
|
||||
import { Component, Form, View } from "effect-view"
|
||||
|
||||
const TextInputView = Component.make("TextInput")(
|
||||
function* (props: {
|
||||
readonly form: Form.Form<readonly PropertyKey[], string, string>
|
||||
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] = yield* View.useAll([props.form.issues])
|
||||
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}
|
||||
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) => (
|
||||
<small key={index}>
|
||||
{issue.message}
|
||||
</small>
|
||||
<small key={index}>{issue.message}</small>
|
||||
))}
|
||||
</label>
|
||||
)
|
||||
@@ -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 (
|
||||
<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.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user