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 ( + + {isCommitting + ? "Saving…" + : `${savedProfile.displayName} is ${savedProfile.age}`} + + ) +}) +``` + +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 ( -
{ - event.preventDefault() - void runPromise(form.submit) - }} - > - - - - - ) - }, -) -``` - -`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.