Add Effect v4 support, Fast Refresh tooling, and revamped docs #56
+228
-116
@@ -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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user