Add debounced form status API and upgrade Effect beta dependencies (#71)
Publish / publish (push) Successful in 3m27s
Lint / lint (push) Successful in 47s

# Add debounced form status API and upgrade Effect beta dependencies

## Summary

- Add `Form.useStatus` for debounced form validation and commit status.
- Expose `isValidating`, `isCommitting`, and `canCommit` as a presentation-friendly status object.
- Support configurable status debounce intervals, defaulting to 250 ms.
- Update form documentation and examples to use `Form.useStatus`.
- Bump `effect-view` to `0.1.2`.
- Upgrade Effect, `@effect/platform-browser`, and `effect-lens` to beta.103.
- Align the example workspace with Effect beta.103.
- Regenerate `bun.lock`.

## Validation

- `bun run lint:tsc`
- `bun run lint:biome`
- `bun run --cwd packages/effect-view test`

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
2026-08-04 21:20:51 +02:00
co-authored by Julien Valverdé
parent 3fef9b1a47
commit 689af07190
5 changed files with 76 additions and 30 deletions
+12 -12
View File
@@ -82,21 +82,21 @@
},
"packages/effect-view": {
"name": "effect-view",
"version": "0.1.0",
"version": "0.1.2",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.102",
"effect-lens": "2.0.1-beta.103",
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102",
"@effect/platform-browser": "4.0.0-beta.103",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"jsdom": "^26.1.0",
"vitest": "^3.2.4",
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"react": "^19.2.0",
},
},
@@ -104,9 +104,9 @@
"name": "@effect-view/example",
"version": "0.0.0",
"dependencies": {
"@effect/platform-browser": "4.0.0-beta.102",
"@effect/platform-browser": "4.0.0-beta.103",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"effect-view": "workspace:*",
"react-icons": "^5.6.0",
},
@@ -3052,9 +3052,9 @@
"@docusaurus/utils/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="],
"@effect-view/example/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.103", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.103" } }, "sha512-3GXGzjv+dB5E9sCCj/JhPVyNB2NrBquGY2tYQ8VQqGK4uOpME6I5oLqRt+MmB7BWqWYPXUqvXj0wvMJRlQC4fw=="],
"@effect-view/example/effect": ["effect@4.0.0-beta.102", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="],
"@effect-view/example/effect": ["effect@4.0.0-beta.103", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-pE8TxF4m2tQzVI+77dIlm3s+81TACV1AiX1JEkvY+zVuxgQQ8aGSkqXNJF6b/ST+coCSk5cUdbULpQ7sm4oHyw=="],
"@effect-view/vite-plugin/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -3122,11 +3122,11 @@
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.102", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.102" } }, "sha512-DXXVt+oOpukqI3lEaGvvXQxSobzIy3lAsIQvlpVK+MuYBm1o4FHZrHKbrOFItDUvSR1t0kiTncFPRi01YaJUmA=="],
"effect-view/@effect/platform-browser": ["@effect/platform-browser@4.0.0-beta.103", "", { "dependencies": { "multipasta": "^0.2.8" }, "peerDependencies": { "effect": "^4.0.0-beta.103" } }, "sha512-3GXGzjv+dB5E9sCCj/JhPVyNB2NrBquGY2tYQ8VQqGK4uOpME6I5oLqRt+MmB7BWqWYPXUqvXj0wvMJRlQC4fw=="],
"effect-view/effect": ["effect@4.0.0-beta.102", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w=="],
"effect-view/effect": ["effect@4.0.0-beta.103", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-pE8TxF4m2tQzVI+77dIlm3s+81TACV1AiX1JEkvY+zVuxgQQ8aGSkqXNJF6b/ST+coCSk5cUdbULpQ7sm4oHyw=="],
"effect-view/effect-lens": ["effect-lens@2.0.1-beta.102", "", { "peerDependencies": { "effect": "4.0.0-beta.102" } }, "sha512-YQSPI/r42Do/24X+RurTd4BLV2EdxG4LcgySpR4Pt7W2Z+wCmoBlSAV+B7rs2lTpqOny3kKTLnn9tbpfQVzSAA=="],
"effect-view/effect-lens": ["effect-lens@2.0.1-beta.103", "", { "peerDependencies": { "effect": "4.0.0-beta.103" } }, "sha512-Z9yiCnC4Dhol68gjTqyj+YZ+ldu1ou5eOGCT7kX01j1/apx/VU6HCYi9bqOoe1i0RG/wr+yCf2kJgodvTXCFfg=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+14 -9
View File
@@ -254,6 +254,13 @@ 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 `Form.useStatus` for displayed form status. It debounces short-lived state
changes, avoiding flicker in pending indicators.
```tsx
const { isValidating, isCommitting, canCommit } = yield* Form.useStatus(form)
```
`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
@@ -299,11 +306,8 @@ export const TextFieldFormInputView = Component.make(
>,
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
const [issues] = yield* View.useAll([props.form.issues])
const { isValidating, isCommitting } = yield* Form.useStatus(props.form)
return (
<Flex direction="column" gap="1">
@@ -351,8 +355,9 @@ They expose the schema pipeline as reactive Lenses and Views:
| `isCommitting` | Whether a mutation or target write is in progress. |
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.
including `Form.useInput`, `Form.useOptionalInput`, and `Form.useStatus`, while
your own component library remains responsible for rendering controls and
layout.
## Example: local date input, UTC domain value
@@ -415,7 +420,7 @@ the mutation receives UTC:
```tsx
import { DateTime, Effect, Schema } from "effect"
import { Component, Form, MutationForm, View } from "effect-view"
import { Component, Form, MutationForm } from "effect-view"
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
const AppointmentSchema = Schema.Struct({
@@ -439,7 +444,7 @@ const AppointmentView = Component.make("Appointment")(function* () {
)
const startsAt = yield* Form.useInput(startsAtField)
const [canCommit] = yield* View.useAll([form.canCommit])
const { canCommit } = yield* Form.useStatus(form)
const runPromise = yield* Component.useRunPromise()
return (
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "effect-view",
"description": "Write React function components with Effect",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"files": [
"./README.md",
@@ -97,19 +97,19 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.102",
"@effect/platform-browser": "4.0.0-beta.103",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.102"
"effect-lens": "2.0.1-beta.103"
}
}
+42 -1
View File
@@ -1,6 +1,6 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import type * as React from "react"
import * as React from "react"
import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as View from "./View.js"
@@ -156,6 +156,47 @@ export const focusChunkAt: {
})
export declare namespace useStatus {
export interface Options {
/**
* The debounce interval applied to changes in the returned status.
*
* Defaults to 250 milliseconds.
*/
readonly debounce?: Duration.Input
}
export interface Success {
readonly isValidating: boolean
readonly isCommitting: boolean
readonly canCommit: boolean
}
}
/**
* Subscribes to form status flags for presentation.
*
* Status changes are debounced to avoid transient UI feedback. The underlying
* form state is not changed.
*/
export const useStatus = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
form: Form<P, A, I, ER, EW>,
options?: useStatus.Options,
): Effect.fn.Return<useStatus.Success, ER, Scope.Scope> {
const views = React.useMemo(() => {
const debounce = options?.debounce ?? "250 millis"
return [
View.mapStream(form.isValidating, Stream.debounce(debounce)),
View.mapStream(form.isCommitting, Stream.debounce(debounce)),
View.mapStream(form.canCommit, Stream.debounce(debounce)),
] as const
}, [form, options?.debounce])
const [isValidating, isCommitting, canCommit] = yield* View.useAll(views)
return { isValidating, isCommitting, canCommit }
})
export namespace useInput {
export interface Options {
readonly debounce?: Duration.Input
+3 -3
View File
@@ -27,15 +27,15 @@
"vite": "^8.0.16"
},
"dependencies": {
"@effect/platform-browser": "4.0.0-beta.102",
"@effect/platform-browser": "4.0.0-beta.103",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"effect-view": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "4.0.0-beta.102",
"effect": "4.0.0-beta.103",
"react": "^19.2.6"
}
}