Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
Showing only changes of commit 4be5b12121 - Show all commits
+114 -49
View File
@@ -283,9 +283,14 @@ the schema defines both directions and the form keeps them synchronized.
## Focus into subforms ## Focus into subforms
After creating either root implementation, focus it along the schema's The root `MutationForm` or `LensForm` represents the complete schema. UI
structure. Focusing an object can produce a nested subform, not only a leaf components usually need a smaller form representing one field, so they do not
field: have to receive or understand the entire root form. **Focusing** creates that
smaller form.
Every root form and every focused subform implements the same `Form.Form`
interface. There is no separate field abstraction: an individual field is
simply a `Form` focused on that part of its parent.
```tsx ```tsx
const displayNameField = Form.focusObjectOn(form, "displayName") const displayNameField = Form.focusObjectOn(form, "displayName")
@@ -295,19 +300,22 @@ const contactForm = Form.focusObjectOn(form, "contact")
const emailField = Form.focusObjectOn(contactForm, "email") const emailField = Form.focusObjectOn(contactForm, "email")
``` ```
`contactForm` can be passed to a component responsible for the whole contact `displayNameField`, `ageField`, and `emailField` can each be passed to the input
section. `emailField` can be passed to a single input. Each focused form narrows component responsible for that field. Focusing can also be repeated:
all of the following to its path: `contactForm` represents the complete contact section, and `emailField`
represents its email field.
A focused form exposes only the relevant:
- `encodedValue`: the UI-facing value that can be edited. - `encodedValue`: the UI-facing value that can be edited.
- `value`: the decoded value as an `Option`. - `value`: the decoded value as an `Option`.
- `issues`: only schema issues at or below the focused path. - `issues`: the schema issues for that subform.
Commit state is intentionally shared with the root. A nested component can use Validation and commit state remain connected to the root form. An input can
`isValidating`, `canCommit`, and `isCommitting` without separately coordinating show its own issues while still observing the root form's `isValidating`,
with its parent. `canCommit`, and `isCommitting` state.
The focusing helpers mirror the kind of data being traversed: Use the focusing helper that matches the value being represented:
```tsx ```tsx
const property = Form.focusObjectOn(form, "contact") const property = Form.focusObjectOn(form, "contact")
@@ -316,28 +324,83 @@ const tupleMember = Form.focusTupleAt(tupleForm, 1)
const chunkItem = Form.focusChunkAt(chunkForm, 0) const chunkItem = Form.focusChunkAt(chunkForm, 0)
``` ```
Because issue paths come from the schema, validation messages follow the same Schema issue paths are focused at the same time. A component receiving
focus operation. A component receiving `emailField` does not need to search the `emailField` gets its email issues directly instead of searching through every
root issue collection for `contact.email` errors. issue on the root form.
## Bind a subform to an input ## Bind a subform to an input
`Form.useInput` connects a focused form to a controlled component. It returns a `Form.useInput` is the liaison between an Effect form and a React controlled
React-style `{ value, setValue }` pair backed by the form's `encodedValue` Lens. component. It reads the subform's encoded value and returns the familiar React
An optional debounce delays schema validation while the user types. `{ value, setValue }` interface. Calling `setValue` updates the form, which in
turn runs the schema pipeline and updates validation state.
```tsx ```tsx
import { Component, Form, View } from "effect-view" const input = yield* Form.useInput(emailField, {
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", debounce: "250 millis",
}) })
return (
<input
value={input.value}
onChange={(event) => input.setValue(event.currentTarget.value)}
/>
)
```
The debounce is useful for text inputs, which can otherwise update the form and
run schema validation on every keystroke. The displayed value still updates
immediately; propagation to the form waits until the user pauses typing.
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.
`useInput` and `useOptionalInput` can be used directly, but they are primarily
building blocks for your own reusable input components. A component library
can wrap them to consistently handle labels, validation issues, validating
indicators, disabled state, accessibility, styling, and a standard debounce.
Because both hooks accept a `Form.Form`, the same input components work with
subforms from both `MutationForm` and `LensForm`.
```tsx title="TextFieldFormInputView.tsx"
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-view"
import type * as React from "react"
export declare namespace TextFieldFormInputView {
export interface Props<
out P extends readonly PropertyKey[],
A,
ER,
EW,
> extends Omit<TextField.RootProps, "form">,
Form.useInput.Options {
readonly form: Form.Form<P, A, string, ER, EW>
}
export type Signature = <
P extends readonly PropertyKey[],
A,
ER,
EW,
>(
props: Props<P, A, ER, EW>,
) => React.ReactNode
}
export const TextFieldFormInputView = Component.make(
"TextFieldFormInputView",
)(function* (
props: TextFieldFormInputView.Props<
readonly PropertyKey[],
any,
any,
any
>,
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([ const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
@@ -345,34 +408,36 @@ const TextInputView = Component.make("TextInput")(
]) ])
return ( return (
<label> <Flex direction="column" gap="1">
{props.label} <TextField.Root
<input
type={props.type}
value={input.value} value={input.value}
onChange={(event) => input.setValue(event.target.value)}
disabled={isCommitting} disabled={isCommitting}
aria-invalid={issues.length > 0} {...Struct.omit(props, ["form", "debounce"])}
onChange={(event) => >
input.setValue(event.currentTarget.value) {isValidating && (
} <TextField.Slot side="right">
/> <Spinner />
{isValidating && <small>Validating...</small>} </TextField.Slot>
{issues.map((issue, index) => ( )}
<small key={index}>{issue.message}</small> {props.children}
))} </TextField.Root>
</label>
{Option.match(Array.head(issues), {
onSome: (issue) => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => null,
})}
</Flex>
) )
}, }).pipe(
Component.withSignature<TextFieldFormInputView.Signature>(),
) )
``` ```
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 ## The common Form model
Both root implementations and every focused subform implement `Form.Form`. Both root implementations and every focused subform implement `Form.Form`.