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
+119 -54
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,63 +324,120 @@ 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, {
debounce: "250 millis",
})
const TextInputView = Component.make("TextInput")( return (
function* (props: { <input
readonly form: Form.Form<readonly PropertyKey[], unknown, string> value={input.value}
readonly label: string onChange={(event) => input.setValue(event.currentTarget.value)}
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 The debounce is useful for text inputs, which can otherwise update the form and
`LensForm`; it only depends on the common `Form` interface. 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 Use `Form.useOptionalInput` when the encoded field is an Effect `Option`. It
returns `value` and `setValue` together with `enabled` and `setEnabled`, making returns `value` and `setValue` together with `enabled` and `setEnabled`, making
it suitable for an optional input that the user can toggle on and off. 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([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={(event) => input.setValue(event.target.value)}
disabled={isCommitting}
{...Struct.omit(props, ["form", "debounce"])}
>
{isValidating && (
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
)}
{props.children}
</TextField.Root>
{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 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`.