10 Commits

Author SHA1 Message Date
renovate-bot 214b500623 Update dependency node to v24
Lint / lint (push) Successful in 48s
Test build / test-build (pull_request) Failing after 2m12s
2026-07-29 12:05:59 +00:00
Thilawyn 4b712de788 Finalize the Effect View rename and refresh docs, examples, and tooling (#59)
Publish / publish (push) Successful in 3m23s
Lint / lint (push) Successful in 48s
## Summary

- Rename `effect-fc-next` to `effect-view` and update package metadata, internal symbols, imports, and repository references.
- Promote the Effect 4 example to `packages/example` and move the legacy Effect 3 example to `packages/effect-fc-example`.
- Add `effect-view` to the npm publishing workflow.
- Update the Vite Fast Refresh plugin for `effect-view` and rename `effectViewPlugin()` to `effectView()`.
- Upgrade Effect, React, TypeScript, build tooling, and related dependencies.
- Refresh the documentation and homepage.

## Documentation

- Add a dedicated Async guide covering:
  - Suspense fallbacks
  - Hook ordering
  - Memoization
  - Structural prop equality
- Clarify synchronous and asynchronous component behavior.
- Document runner scope and service requirements.
- Explain stable construction of Query, Mutation, and Form instances.
- Expand focused Lens and Form examples, including curried focus helpers.
- Document the optional `@effect/platform-browser` dependency for window-focus query refresh.
- Improve the Forms guide structure and examples.
- Fix responsive homepage headline wrapping and update homepage copy.

## Notable API changes

- Package rename:

  ```diff
  - effect-fc-next
  + effect-view

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #59
2026-07-27 04:11:42 +02:00
Julien Valverdé ec21d49dce Fix Docker image name
Publish / publish (push) Successful in 3m37s
Lint / lint (push) Successful in 57s
2026-07-26 02:42:15 +02:00
Thilawyn 4478598f15 Add Effect v4 support, Fast Refresh tooling, and revamped docs (#56)
Lint / lint (push) Successful in 57s
Publish / publish (push) Failing after 57s
## Summary

- Add `effect-fc-next`, a React 19 integration targeting Effect v4 beta.
- Introduce component lifecycles, scoped resources, queries, mutations, forms, lenses, views, and refreshable components.
- Add `@effect-view/vite-plugin` for Vite Fast Refresh support.
- Add an Effect v4 example application covering the new APIs.
- Expand test coverage for both the existing and next-generation packages.
- Replace the starter Docusaurus content with complete Effect View documentation while preserving the Effect v3 docs as a versioned snapshot.
- Update the landing page, navigation, package scripts, and build output.
- Extend CI with linting, tests, package builds, Docker builds, and container publishing.

## Validation

- `bun lint:tsc`
- `bun lint:biome`
- `bun test`
- `bun run build`
- `bun pack`
- Docker image build

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: Thilawyn/effect-fc#56
2026-07-26 02:32:59 +02:00
Thilawyn 721ab7d736 0.3.0 (#50)
Publish / publish (push) Successful in 26s
Lint / lint (push) Successful in 13s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: Thilawyn/effect-fc#50
2026-06-04 17:06:51 +02:00
Thilawyn e5d0808b02 0.2.6 (#49)
Publish / publish (push) Successful in 30s
Lint / lint (push) Successful in 14s
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: Thilawyn/effect-fc#49
2026-05-04 02:10:53 +02:00
Thilawyn ff13e941e3 0.2.5 (#43)
Publish / publish (push) Successful in 52s
Lint / lint (push) Successful in 14s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: Thilawyn/effect-fc#43
2026-03-31 21:01:12 +02:00
Thilawyn 67b01d4621 0.2.4 (#38)
Publish / publish (push) Successful in 59s
Lint / lint (push) Successful in 15s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: Thilawyn/effect-fc#38
2026-03-16 00:30:17 +01:00
Thilawyn 092737076f 0.2.3 (#33)
Publish / publish (push) Successful in 19s
Lint / lint (push) Successful in 12s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: Thilawyn/effect-fc#33
2026-01-23 01:50:12 +01:00
Thilawyn 0e8adf8506 0.2.2 (#31)
Lint / lint (push) Successful in 11s
Publish / publish (push) Successful in 18s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: Thilawyn/effect-fc#31
2026-01-16 17:05:30 +01:00
160 changed files with 18797 additions and 2191 deletions
+2
View File
@@ -16,3 +16,5 @@ jobs:
run: bun lint:tsc
- name: Lint Biome
run: bun lint:biome
- name: Test
run: bun run test
+46
View File
@@ -11,16 +11,35 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: docker.valverde.cloud
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Clone repo
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint TypeScript
run: bun lint:tsc
- name: Lint Biome
run: bun lint:biome
- name: Test
run: bun run test
- name: Build
run: bun run build
- name: Publish effect-view
uses: JS-DevTools/npm-publish@v4
with:
package: packages/effect-view
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Publish effect-fc
uses: JS-DevTools/npm-publish@v4
with:
@@ -28,3 +47,30 @@ jobs:
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
- name: Clean before Docker build
run: |
bun clean:cache
bun clean:dist
bun clean:modules
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
docker.valverde.cloud/thilawyn/effect-view
tags: |
type=ref,event=branch
type=ref,event=tag
type=ref,event=pr
type=sha
flavor: |
latest=true
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+33
View File
@@ -13,15 +13,48 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Clone repo
uses: actions/checkout@v6
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint TypeScript
run: bun lint:tsc
- name: Lint Biome
run: bun lint:biome
- name: Test
run: bun run test
- name: Build
run: bun run build
- name: Pack
run: bun pack
- name: Clean before Docker build
run: |
bun clean:cache
bun clean:dist
bun clean:modules
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
docker.valverde.cloud/thilawyn/effect-view
tags: |
type=ref,event=branch
type=ref,event=tag
type=ref,event=pr
type=sha
flavor: |
latest=true
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+12
View File
@@ -0,0 +1,12 @@
FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bunx /usr/local/bin/
COPY . /app
WORKDIR /app
RUN bun install --frozen-lockfile && \
bun run build && \
bun clean:cache && \
bun clean:modules && \
bun install --production --frozen-lockfile
+59 -5
View File
@@ -1,7 +1,61 @@
# Effect FC Monorepo
<p align="center">
<a href="https://thila.dev/effect-view">
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
</a>
</p>
[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
<h1 align="center">Effect View Monorepo</h1>
This monorepo contains:
- [The `effect-fc` library](packages/effect-fc)
- [An example project](packages/example)
<p align="center">
Write React components as typed Effect programs.
</p>
This repository contains Effect View, its documentation, development examples,
and supporting tooling. Effect View brings Effect's typed services, resource
safety, concurrency, and data modeling to React while keeping ordinary React
components, hooks, and JSX.
## Packages
| Package | Description |
| --- | --- |
| [`effect-view`](packages/effect-view) | The Effect View library for Effect v4 and React 19. |
| [`@effect-view/vite-plugin`](packages/vite-plugin) | Vite Fast Refresh support for Effect View components. |
| [`docs`](packages/docs) | The [Effect View documentation](https://thila.dev/effect-view), built with Docusaurus. |
| [`example`](packages/example) | Example application using Effect View and Effect v4. |
| [`effect-fc`](packages/effect-fc) | Legacy Effect v3 package. |
| [`effect-fc-example`](packages/effect-fc-example) | Example application for the legacy package. |
## Development
The monorepo uses [Bun](https://bun.sh/) workspaces and
[Turborepo](https://turbo.build/).
```bash
bun install
```
Run checks and builds from the repository root:
```bash
bun run lint:tsc
bun run lint:biome
bun run test
bun run build
```
Start the Effect View example or documentation site:
```bash
bun run --cwd packages/example dev
bun run --cwd packages/docs start
```
## Documentation
Read the documentation at
[thila.dev/effect-view](https://thila.dev/effect-view).
## License
[MIT](LICENSE)
+3091 -300
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,26 +1,27 @@
{
"name": "@effect-fc/monorepo",
"packageManager": "bun@1.3.3",
"name": "@effect-view/monorepo",
"packageManager": "bun@1.3.14",
"private": true,
"workspaces": [
"./packages/*"
],
"scripts": {
"build": "turbo build",
"lint:tsc": "turbo lint:tsc",
"lint:biome": "turbo lint:biome",
"test": "turbo test",
"build": "turbo build",
"pack": "turbo pack",
"clean:cache": "turbo clean:cache",
"clean:dist": "turbo clean:dist",
"clean:modules": "turbo clean:modules && rm -rf node_modules"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@effect/language-service": "^0.65.0",
"@types/bun": "^1.3.3",
"npm-check-updates": "^19.1.2",
"@biomejs/biome": "^2.5.5",
"@effect/language-service": "^0.87.1",
"@types/bun": "^1.3.14",
"npm-check-updates": "^23.0.0",
"npm-sort": "^0.0.4",
"turbo": "^2.6.1",
"typescript": "^5.9.3"
"turbo": "^2.10.7",
"typescript": "^7.0.2"
}
}
+20
View File
@@ -0,0 +1,20 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+17
View File
@@ -0,0 +1,17 @@
# effect-fc Docs
The documentation site is built with Docusaurus.
## Local Development
```bash
bun run --cwd packages/docs start
```
## Build
```bash
bun run --cwd packages/docs build
```
The static site is written to `packages/docs/build`.
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
},
"linter": {
"rules": {
"complexity": {
"noImportantStyles": "off"
},
"style": {
"noDescendingSpecificity": "off"
}
}
}
}
+145
View File
@@ -0,0 +1,145 @@
---
sidebar_position: 2
title: Async
---
# Async
Effect View components run synchronously by default. Apply `Async.async` when a
component must wait for an asynchronous Effect before it can return JSX. The
component suspends while the Effect runs and accepts React Suspense props such
as `fallback`.
```tsx title="src/UserCard.tsx"
import { Effect } from "effect"
import { Async, Component } from "effect-view"
import { loadUser } from "./api"
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return <article>{user.name}</article>
},
).pipe(Async.async)
```
Pass a fallback when rendering the component:
```tsx
const User = yield* UserCard.use
return <User userId="123" fallback={<p>Loading user...</p>} />
```
Or configure a default fallback once:
```tsx
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return <article>{user.name}</article>
},
).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
)
```
## Hook ordering
**Important:** Do not use React hooks or Effect View hook helpers after an
asynchronous computation in the component body. After the computation suspends,
the generator continuation runs outside React's synchronous render phase.
Place every hook before the first operation that may suspend:
```tsx
import { useState } from "react"
const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
// Hooks and hook helpers go before asynchronous work.
const [showDetails, setShowDetails] = useState(false)
// This may suspend, so no hooks may be used after it.
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return (
<article>
<button onClick={() => setShowDetails((value) => !value)}>
{user.name}
</button>
{showDetails && <p>{user.bio}</p>}
</article>
)
},
).pipe(Async.async)
```
## Memoization
An async computation starts whenever its component renders. Apply
`Memoized.memoized` after `Async.async` to prevent an unrelated parent render
from restarting an async child whose props have not changed:
```tsx
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return <article>{user.name}</article>
},
).pipe(
Async.async,
Memoized.memoized,
)
```
Async components compare props with `Object.is` by default and ignore the
`fallback` prop during that comparison. A changed `userId` renders the child
again, closes the previous dependency scope, and runs `loadUser` for the new
value.
If props contain immutable objects or arrays that may be recreated with the same
content, use `Equal.asEquivalence()` for full structural equality:
```tsx
import { Equal } from "effect"
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(
function* (props: { readonly user: { readonly id: string } }) {
const details = yield* Component.useOnChange(
() => loadUser(props.user.id),
[props.user.id],
)
return <article>{details.name}</article>
},
).pipe(
Async.async,
Memoized.memoized,
Memoized.withOptions({
propsEquivalence: Equal.asEquivalence(),
}),
)
```
Supplying `propsEquivalence` replaces the async component's default comparison,
so `fallback` is also included in this structural comparison.
+475
View File
@@ -0,0 +1,475 @@
---
sidebar_position: 5
title: Forms
---
# Forms
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.
A schema distinguishes the **encoded value** edited by the UI from the
**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:
```tsx
import { Schema } from "effect"
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",
}),
),
}),
})
```
That schema gives the form several useful properties:
- 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.
There are two concrete root form implementations. Choose one based on where a
valid decoded value should go:
| 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`. |
## Create forms once
`MutationForm.service` and `LensForm.service` are Effect constructors, not
hooks. Create each root form once and keep it stable. For a component-owned
form, call the constructor inside `Component.useOnMount`; for a form shared by
multiple components, create it in an Effect service. Update the existing form's
Lenses rather than reconstructing the form during render.
## 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 (
<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. In
the example above, `Component.useOnMount` keeps that validation and the form's
mutation work tied to the component that owns them.
## 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
The root `MutationForm` or `LensForm` represents the complete schema. UI
components usually need a smaller form representing one field, so they do not
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
const displayNameField = Form.focusObjectOn(form, "displayName")
const ageField = Form.focusObjectOn(form, "age")
const contactForm = Form.focusObjectOn(form, "contact")
const emailField = Form.focusObjectOn(contactForm, "email")
```
Form focus helpers also have a dual API. Pass only the key or index to get a curried function. Curried helpers can be chained
with `pipe` to focus through a deeper path:
```tsx
const emailField = form.pipe(
Form.focusObjectOn("contact"),
Form.focusObjectOn("email"),
)
```
`displayNameField`, `ageField`, and `emailField` can each be passed to the input
component responsible for that field. Focusing can also be repeated:
`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.
- `value`: the decoded value as an `Option`.
- `issues`: the schema issues for that subform.
Validation and commit state remain connected to the root form. An input can
show its own issues while still observing the root form's `isValidating`,
`canCommit`, and `isCommitting` state.
Use the focusing helper that matches the value being represented:
```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)
```
Schema issue paths are focused at the same time. A component receiving
`emailField` gets its email issues directly instead of searching through every
issue on the root form.
## Bind a subform to an input
`Form.useInput` is the liaison between an Effect form and a React controlled
component. It reads the subform's encoded value and returns the familiar React
`{ value, setValue }` interface. Calling `setValue` updates the form, which in
turn runs the schema pipeline and updates validation state.
```tsx
const input = yield* Form.useInput(emailField, {
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([
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
Both root implementations and every focused subform implement `Form.Form`.
They expose the schema pipeline as reactive Lenses and Views:
| 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. |
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.
## Example: local date input, UTC domain value
The encoded/decoded distinction is especially useful for dates. An HTML
`datetime-local` input produces a wall-clock string such as
`"2026-07-22T14:30"`. It contains no time-zone or UTC offset, while application
state and APIs usually need an unambiguous UTC instant.
A schema can own that entire conversion. The following schema interprets the
input in the current time zone and decodes it to `DateTime.Utc`:
```tsx title="src/DateTimeUtcFromZonedInput.ts"
import { DateTime, Effect, Option, ParseResult, Schema } from "effect"
class DateTimeUtcFromZoned extends Schema.transformOrFail(
Schema.DateTimeZonedFromSelf,
Schema.DateTimeUtcFromSelf,
{
strict: true,
decode: (input) => ParseResult.succeed(DateTime.toUtc(input)),
encode: DateTime.setZoneCurrent,
},
) {}
export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(
Schema.String,
DateTimeUtcFromZoned,
{
strict: true,
decode: (input, _options, ast) =>
Effect.flatMap(DateTime.CurrentTimeZone, (timeZone) =>
Option.match(
DateTime.makeZoned(input, {
timeZone,
adjustForTimeZone: true,
}),
{
onSome: ParseResult.succeed,
onNone: () =>
ParseResult.fail(
new ParseResult.Type(
ast,
input,
"Enter a valid date and time",
),
),
},
),
),
encode: (value) =>
ParseResult.succeed(
DateTime.formatIsoZoned(value).slice(0, 16),
),
},
) {}
```
Use it like any other field schema. The form and input work with a string, but
the mutation receives UTC:
```tsx
import { DateTime, Effect, Schema } from "effect"
import { Component, Form, MutationForm, View } from "effect-view"
import { DateTimeUtcFromZonedInput } from "./DateTimeUtcFromZonedInput"
const AppointmentSchema = Schema.Struct({
startsAt: DateTimeUtcFromZonedInput,
})
const AppointmentView = Component.make("Appointment")(function* () {
const [form, startsAtField] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const form = yield* MutationForm.service({
schema: AppointmentSchema,
initialEncodedValue: { startsAt: "" },
f: ([appointment]) =>
Effect.log(
`Saving ${DateTime.formatIso(appointment.startsAt)}`,
),
})
return [form, Form.focusObjectOn(form, "startsAt")] as const
}),
)
const startsAt = yield* Form.useInput(startsAtField)
const [canCommit] = yield* View.useAll([form.canCommit])
const runPromise = yield* Component.useRunPromise()
return (
<form
onSubmit={(event) => {
event.preventDefault()
void runPromise(form.submit)
}}
>
<input
type="datetime-local"
value={startsAt.value}
onChange={(event) =>
startsAt.setValue(event.currentTarget.value)
}
/>
<button disabled={!canCommit}>Save appointment</button>
</form>
)
})
```
The schema requires `DateTime.CurrentTimeZone`; provide
`DateTime.layerCurrentZoneLocal` in the application runtime. If the current
zone is `Europe/Paris`, for example, entering `2026-07-22T14:30` decodes to the
UTC instant `2026-07-22T12:30:00.000Z`.
Encoding works in the other direction. A `LensForm` whose target contains that
UTC instant presents `2026-07-22T14:30` to the local input. The component never
manually parses dates, applies offsets, or maintains a second representation;
the schema defines both directions and the form keeps them synchronized.
+531
View File
@@ -0,0 +1,531 @@
---
sidebar_position: 1
title: Getting Started
---
# Getting Started
`effect-view` lets a React function component be described as an Effect
program. Inside a component you can yield services, create scoped resources,
subscribe to Effect-powered state, and turn Effects into React callbacks. At a
React boundary, that description becomes a normal function component.
The core model has four pieces:
1. `ReactRuntime` builds the Effect services available to the UI.
2. `Component.make` defines an Effect View component.
3. `Component.withContext` converts an Effect View component into a normal
React component at an application or router boundary.
4. Effect View children are composed through their `.use` Effect.
## Install
For a web application, install Effect View with Effect 4 and React 19.2 or
newer:
```bash npm2yarn
npm install effect-view effect@beta react react-dom
```
```bash npm2yarn
npm install --save-dev @types/react @types/react-dom
```
Effect View is not tied to React DOM. For React Native or another renderer,
install that renderer instead of `react-dom` and keep the rest of the setup the
same.
## Set up hot reloading with Vite
Install the Effect View Vite plugin to preserve component state while editing
Effect View components:
```bash npm2yarn
npm install --save-dev @effect-view/vite-plugin
```
Add `effectView()` before the React plugin in your Vite configuration:
```ts title="vite.config.ts"
import { effectView } from "@effect-view/vite-plugin"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
effectView(),
react(),
],
})
```
Vite is the only bundler supported for Effect View hot reloading at the moment.
Support for other bundlers will follow.
## Create the runtime
`ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
layers needed by the UI:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
```
Keep the runtime stable. Creating it during a React render would create new
managed resources and a new React context on every render.
## Provide the runtime
Place `ReactRuntime.Provider` above every Effect View entrypoint. The provider
builds the runtime layer, makes its Effect context available through React, and
disposes the managed runtime when the provider unmounts.
Runtime construction can suspend, so give the provider a fallback:
```tsx title="src/main.tsx"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ReactRuntime } from "effect-view"
import { App } from "./App"
import { runtime } from "./runtime"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider
runtime={runtime}
fallback={<p>Starting application...</p>}
>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
```
With a router, keep the provider above the router provider:
```tsx
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
<RouterProvider router={router} />
</ReactRuntime.Provider>
```
If runtime layer construction can fail, place an appropriate React error
boundary above `ReactRuntime.Provider` as well.
## Write your first component
`Component.make` defines a component body using the same generator style as
`Effect.gen` and `Effect.fn`. Providing a name creates a tracing span and also
sets the React DevTools display name:
```tsx title="src/HelloView.tsx"
import { Effect } from "effect"
import { Component } from "effect-view"
export const HelloView = Component.make("HelloView")(
function* (props: { readonly name: string }) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
},
)
```
Use `Component.makeUntraced("HelloView")` when you want the display name but do
not want an automatic tracing span.
`HelloView` is an Effect View component description, not yet a normal React
component. Convert it at the point where plain React, a router, or a third-party
library needs a function component:
```tsx title="src/Hello.tsx"
import { Component } from "effect-view"
import { HelloView } from "./HelloView"
import { runtime } from "./runtime"
export const Hello = HelloView.pipe(
Component.withContext(runtime.context),
)
```
It can now be rendered by ordinary React:
```tsx title="src/App.tsx"
import { Hello } from "./Hello"
export function App() {
return <Hello name="Effect" />
}
```
`Component.withContext` reads the context populated by the matching
`ReactRuntime.Provider`. It does not build or provide the runtime by itself.
## Compose Effect View components
Inside another Effect View component, yield a child's `.use` Effect. This binds
the child to the current Effect context and returns a component that can be used
in JSX:
```tsx title="src/GreetingCardView.tsx"
import { Component } from "effect-view"
import { HelloView } from "./HelloView"
export const GreetingCardView = Component.make("GreetingCard")(
function* () {
const Hello = yield* HelloView.use
return (
<section>
<Hello name="Effect" />
<p>Both components use the same Effect context.</p>
</section>
)
},
)
```
Only apply `Component.withContext` when crossing from Effect View into plain
React. Applying it to every nested component is unnecessary and makes it harder
to provide local services to a subtree.
## Synchronous and asynchronous components
A regular Effect View component runs its body during React render. Effects
executed directly by that body—including setup passed to `useOnMount`,
`useOnChange`, or `useLayer`—must complete synchronously every time they run.
Yielding services and reading synchronous state is fine; sleeping, fetching, or
awaiting a promise is not.
Choose the integration that matches the asynchronous work:
- Make the component asynchronous with [`Async.async`](./async) when it must
wait for a one-off asynchronous Effect before producing JSX. The component
suspends while it waits.
- Use [Query](./query) for server reads that need caching, sharing, refresh, or
invalidation.
- Use [Mutation](./mutation) for user-triggered writes with observable pending
and error state.
- Use `useRunPromise` or `useCallbackPromise` for asynchronous event work that
does not need Mutation state.
- Use a post-commit hook with a scoped fiber for subscriptions or background
work tied to the component lifecycle.
## Use Effect services
Components can yield Effect service tags directly. Their required service type
becomes part of the component type, so the final runtime or a local layer must
provide it:
```tsx title="src/GreetingService.ts"
import { Context, Layer } from "effect"
export class GreetingService extends Context.Service<
GreetingService,
{ readonly greet: (name: string) => string }
>()("GreetingService") {
static readonly layer = Layer.succeed(GreetingService, {
greet: (name) => `Hello, ${name}`,
})
}
```
```tsx title="src/GreetingView.tsx"
import { Component } from "effect-view"
import { GreetingService } from "./GreetingService"
export const GreetingView = Component.make("Greeting")(
function* (props: { readonly name: string }) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
},
)
```
Add `GreetingService.layer` to the application runtime, or provide it only to a
subtree as shown later on this page.
Effect service instances are reactive at component boundaries. If the supplied
context changes to contain a different service instance, dependent Effect View
components are recreated so they read the new environment and restart their
scoped lifecycle.
## Understand lifecycle hooks
Effect View hooks are still React hooks internally. Call them unconditionally
at the top level of the component body, in a consistent order, and never inside
branches, loops, event handlers, or nested callbacks.
### The component root scope
Every rendered Effect View component gets a root `Scope.Scope`. Effect View
creates it when the component instance is rendered, provides it to the entire
component body, and closes it when that React component unmounts.
Effects yielded directly from the body therefore see the root scope. This also
means that `Effect.addFinalizer`, `Effect.acquireRelease`, and
`Effect.forkScoped` can be used naturally inside component setup:
```tsx
import { Effect } from "effect"
import { Component } from "effect-view"
const ResourceView = Component.make("Resource")(function* () {
const resource = yield* Component.useOnMount(() =>
Effect.acquireRelease(
openResource,
(resource) => closeResource(resource),
),
)
return <p>{resource.name}</p>
})
```
`useOnMount` does **not** create or provide another scope. It runs its Effect
with the context already available in the component body, so the resource above
is owned by the component's root scope.
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each manage their
own resources. When their dependencies change, those resources are cleaned up
and recreated while the rest of the component remains active.
The main lifecycle choices are:
| Hook | What it does | Scope seen by setup | Scope closes when |
| --- | --- | --- | --- |
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
| `useOnMount` | Computes and caches a value for the component instance | Component root scope | The component unmounts |
| `useOnChange` | Recomputes a cached value when dependencies change | A new dependency scope | Dependencies change or the component unmounts |
| `useReactEffect` | Runs a post-commit side effect | A new effect scope | Dependencies change or the component unmounts |
| `useReactLayoutEffect` | Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
| `useLayer` | Builds and provides a layer context | A new scope tied to the current layer reference | The layer reference changes or the component unmounts |
`useRunSync` and `useRunPromise` do not create a new scope. The runner they
return includes the component root scope in its context by default.
`useCallbackSync` and `useCallbackPromise` similarly capture the component
context required by their Effect.
### useOnMount
`Component.useOnMount` computes a value during the initial render and caches it
for later renders. It uses the component root scope, so its resources live
until the component unmounts.
```tsx title="src/LocalStateView.tsx"
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-view"
export const LocalStateView = Component.make("LocalState")(
function* () {
const count = yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.log("LocalState disposed"),
)
return Lens.fromSubscriptionRef(
yield* SubscriptionRef.make(0),
)
}),
)
const [value] = yield* View.useAll([count])
return <p>Count: {value}</p>
},
)
```
In a regular component, the setup Effect must be synchronous because it runs
during render. In a component enhanced with `Async.async`, it may be
asynchronous and will suspend the component.
### useOnChange
`Component.useOnChange` computes and caches a value, then recomputes it when its
dependencies change. Each dependency set gets its own scope, which closes when
the dependencies change or the component unmounts.
```tsx
const label = yield* Component.useOnChange(
() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.log(`Stopped viewing ${props.userId}`),
)
return `Viewing user ${props.userId}`
}),
[props.userId],
)
```
Dependency arrays follow normal React semantics. Include every reactive value
read by the setup Effect.
### useReactEffect and useReactLayoutEffect
`Component.useReactEffect` runs side effects after React commits, while
`Component.useReactLayoutEffect` runs before the browser paints. Each hook owns
a scope that closes when its dependencies change or the component unmounts.
Setup must complete synchronously, but it can fork asynchronous work into the
hook scope:
```tsx
yield* Component.useReactEffect(
() => Effect.forkScoped(listenForNotifications(props.userId)),
[props.userId],
)
```
React Strict Mode may intentionally repeat development-only render and effect
setup. Initializers and resource acquisition should therefore be safe to run
more than once; production retains the normal component lifetime semantics.
## Run Effects from event handlers
React event handlers are plain functions. Use Effect View runners to execute an
Effect with the component's current context and scope:
```tsx
const runPromise = yield* Component.useRunPromise()
return (
<button onClick={() => void runPromise(saveUser(user))}>
Save
</button>
)
```
Use `Component.useRunSync` only for Effects known to complete synchronously.
Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
otherwise continue asynchronously.
Both runners provide `Scope.Scope` by default, so they can run scoped Effects
without a type argument. When an Effect also requires application services,
declare the complete runner context explicitly:
```tsx
import { Scope } from "effect"
const runPromise = yield* Component.useRunPromise<
Scope.Scope | UserRepository
>()
```
The resulting runner accepts Effects that require the component scope,
`UserRepository`, or both.
When a callback is passed to a memoized child or used as a dependency, use the
callback variants to preserve its identity:
```tsx
const save = yield* Component.useCallbackPromise(
(nextUser: User) => saveUser(nextUser),
[saveUser],
)
return <SaveButton onSave={() => void save(user)} />
```
`useCallbackSync` and `useCallbackPromise` follow the same dependency rules as
`React.useCallback`, while also supplying the Effect context when invoked.
## Use React normally
Effect View components can use regular React hooks, refs, context, event
handlers, and JSX composition:
```tsx
import { Component } from "effect-view"
import * as React from "react"
const CounterView = Component.make("Counter")(function* () {
const [count, setCount] = React.useState(0)
const buttonRef = React.useRef<HTMLButtonElement>(null)
return (
<button
ref={buttonRef}
onClick={() => setCount((count) => count + 1)}
>
Count: {count}
</button>
)
})
```
Prefer regular React state for simple, component-local UI concerns. Use
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
sharing. The [State Management guide](./state-management) covers that model.
## Provide services to a subtree
Use `Component.useLayer` when only one Effect View subtree needs extra
services. It builds the layer in a scope and returns the resulting Effect
context:
```tsx title="src/GreetingPageView.tsx"
import { Effect } from "effect"
import { Component } from "effect-view"
import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService"
export const GreetingPageView = Component.make("GreetingPage")(
function* () {
const context = yield* Component.useLayer(GreetingService.layer)
const Greeting = yield* GreetingView.use.pipe(
Effect.provide(context),
)
return <Greeting name="Effect" />
},
)
```
Keep the layer reference stable. Define static layers outside the component or
memoize a layer that depends on props with `React.useMemo`; a new layer object
causes reconstruction and scoped cleanup.
Layer construction runs during render through `useOnChange`. A layer with
asynchronous acquisition requires the component to be enhanced with
`Async.async`. Resources built by the layer are released when the owning
component unmounts or the layer reference changes.
## Common pitfalls
- Effect View hooks obey React's Rules of Hooks even though they are called
with `yield*`.
- Do not yield an asynchronous Effect from a regular component body. Use
`Async.async`, Query, a Mutation callback, or a post-commit scoped fiber.
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it.
- Apply `withContext` at React boundaries, not between Effect View components.
- Keep runtimes and layers stable instead of creating them on every render.
- Use `useRunPromise` rather than `useRunSync` for asynchronous event work.
- Suspense fallbacks handle waiting; React error boundaries handle failed
component Effects.
## Where to go next
- [State Management](./state-management) explains `Lens`, `View`, local state,
and focused state.
- [Query](./query) adds reactive keys, caching, refresh, and invalidation to
Effect-based server reads.
- [Mutation](./mutation) models user-triggered Effect operations and their
`AsyncResult` state.
- [Forms](./forms) builds schema-driven editable state with `MutationForm` and
`LensForm`.
The repeatable pattern is small: provide one runtime, define components as
Effect programs, cross into plain React with `Component.withContext`, and use
`.use` everywhere inside the Effect View tree.
+275
View File
@@ -0,0 +1,275 @@
---
sidebar_position: 4
title: Mutation
---
# Mutation
The Mutation module is Effect View's counterpart to TanStack Query mutations.
It models user-triggered asynchronous work such as saving a profile, deleting a
record, uploading a file, or sending a command to an API.
The familiar concepts are present, but the operation itself is an Effect:
| TanStack Mutation concept | Effect View equivalent |
| --- | --- |
| Mutation variables | The input key `K` |
| `mutationFn` | `f: (key: K) => Effect<A, E, R>` |
| Mutation result | `mutation.state`, a `View<AsyncResult<A, E>>` |
| `isPending` | `result.waiting` |
| `mutateAsync` | `mutation.mutate(key)` |
| Start without awaiting | `mutation.mutateView(key)` |
Unlike a Query, a Mutation has no cache, reactive key, or automatic execution.
It runs only when `mutate` or `mutateView` is called. This makes it suitable for
commands, while Query remains the model for cached server state.
## Create a mutation
Create a mutation in a component scope with `Mutation.make`. Its function can
be any Effect, including one that requires services from the application
runtime:
`Mutation.make` is an Effect constructor, not a hook. Create each mutation
instance once and keep it stable instead of calling `Mutation.make` again on
every render. Use `Component.useOnMount` for a component-owned mutation or
create it in an Effect service when it should be shared.
```tsx
import { Effect } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Mutation, View } from "effect-view"
import { sendInvite } from "./api"
interface InviteInput {
readonly email: string
readonly role: "member" | "admin"
}
const InviteButtonView = Component.make("InviteButton")(
function* (props: { readonly email: string }) {
const mutation = yield* Component.useOnMount(() =>
Mutation.make({
f: (input: InviteInput) => sendInvite(input),
}),
)
const [result] = yield* View.useAll([mutation.state])
const runSync = yield* Component.useRunSync()
return (
<section>
<button
disabled={result.waiting}
onClick={() =>
runSync(mutation.mutateView({
email: props.email,
role: "member",
}))
}
>
{result.waiting ? "Sending..." : "Send invite"}
</button>
{AsyncResult.match(result, {
onInitial: () => null,
onFailure: ({ cause }) => (
<p>Could not send invite: {cause.toString()}</p>
),
onSuccess: ({ value }) => (
<p>Invite sent to {value.email}</p>
),
})}
</section>
)
},
)
```
`Mutation.make` captures the current Effect context. If `sendInvite` requires
an HTTP client, authentication service, tracer, or another service, provide it
to the runtime or component layer before creating the mutation. The click
handler does not need to reconstruct those dependencies.
The mutation also belongs to the creation scope. Any mutation fibers still
running when that scope closes are interrupted automatically.
## Understand AsyncResult
`mutation.state` starts as `Initial` with `waiting: false`. Starting a mutation
changes it to a waiting state, then publishes either `Success` or `Failure`:
```tsx
const [result] = yield* View.useAll([mutation.state])
return AsyncResult.match(result, {
onInitial: ({ waiting }) =>
waiting ? <p>Starting...</p> : <p>Ready.</p>,
onFailure: ({ cause, previousSuccess, waiting }) => (
<div>
<p>{cause.toString()}</p>
{previousSuccess._tag === "Some" && (
<p>Last saved value: {previousSuccess.value.value.name}</p>
)}
{waiting && <p>Trying again...</p>}
</div>
),
onSuccess: ({ value, waiting }) => (
<div>
<p>Saved {value.name}</p>
{waiting && <p>Saving a newer value...</p>}
</div>
),
})
```
The `waiting` flag is independent from the result tag. After one successful
call, starting another keeps the successful value and sets `waiting: true`.
If that next call fails, the failure can retain the earlier success in
`previousSuccess`. Components can therefore keep useful feedback visible
during retries or repeated submissions.
A failure contains an Effect `Cause<E>`, not only `E`. Typed failures, defects,
and interruption information remain available for logging or presentation.
## Choose mutate or mutateView
Both methods start the same mutation effect. They differ in what the caller
waits for:
| Method | Return value | Best suited to |
| --- | --- | --- |
| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. |
| `mutateView(key)` | A live `View<AsyncResult<A, E>>` | UI callbacks that should return after starting the work. |
Use `mutate` with an asynchronous component runner when later logic depends on
the final result:
```tsx
const runPromise = yield* Component.useRunPromise()
const save = () =>
void runPromise(
Effect.gen(function* () {
const result = yield* mutation.mutate(input)
if (AsyncResult.isSuccess(result)) {
yield* Effect.log(`Saved record ${result.value.id}`)
}
}),
)
```
The mutation Effect does not fail with `E`. It captures the operation's `Exit`
and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or
match that value when control flow depends on the outcome.
Use `mutateView` when the UI only needs to start the operation and react to its
state:
```tsx
const runSync = yield* Component.useRunSync()
const startSave = () => {
const state = runSync(mutation.mutateView(input))
// `state` is a View for this specific call.
}
```
`mutateView` starts the scoped work and returns immediately with a per-call
View. The shared `mutation.state` is also updated as that call progresses.
## Track the latest call
In addition to `state`, a mutation exposes reactive metadata:
| Member | Meaning |
| --- | --- |
| `state` | The latest mutation state published to the shared View. |
| `latestKey` | The most recently supplied input as an `Option<K>`. |
| `latestFinalResult` | The latest completed success or failure as an `Option`. |
| `fiber` | The most recently started mutation fiber as an `Option`. |
Subscribe to any of them with `View.useAll`:
```tsx
const [result, latestInput, latestFinal] = yield* View.useAll([
mutation.state,
mutation.latestKey,
mutation.latestFinalResult,
])
```
The input is called a `key` in the generic API, but it is equivalent to
mutation variables in TanStack Query. It can be a primitive, tuple, struct, or
any other value accepted by the mutation function.
## Concurrent mutations
Starting a mutation does not automatically interrupt an earlier mutation.
Calls may overlap, and each call has its own state View. This is useful for
independent operations such as uploading several files.
When calls overlap, `mutation.state` reflects updates published by all calls;
the last update to arrive wins. Use the View returned by `mutateView` when each
concurrent operation needs its own progress indicator:
```tsx
const upload = (file: File) =>
Effect.gen(function* () {
const uploadState = yield* mutation.mutateView(file)
// Store or pass `uploadState` to the row rendering this file.
return uploadState
})
```
For a single submit button, disabling it while `mutation.state.waiting` is
usually enough to prevent accidental overlap. For “latest request wins”
behavior, model that policy explicitly or use Query when the operation is
actually a reactive read.
## Update queries after a mutation
Mutations do not invalidate Query caches automatically. Compose invalidation
with the mutation result so the relationship remains explicit and typed:
```tsx
const saveAndRefresh = (input: UpdatePostInput) =>
Effect.gen(function* () {
const result = yield* updatePost.mutate(input)
if (AsyncResult.isSuccess(result)) {
yield* posts.invalidateCacheEntry(["post", result.value.id] as const)
yield* posts.refreshView
}
return result
})
```
This is the Effect equivalent of an `onSuccess` callback that invalidates a
TanStack Query. Because it is ordinary Effect composition, the workflow can
also include tracing, transactions, retries, notifications, or parallel cache
updates without introducing a separate callback API.
Remember that Query invalidation removes cached data but does not refetch by
itself. Follow it with `refreshView` when the current screen should update
immediately.
## The Effect touch
Mutation follows the ergonomics of TanStack mutations while retaining Effect's
execution model:
- The mutation function has the full `Effect<A, E, R>` type.
- Required services are captured from the creation context.
- Failures are represented as `Cause<E>`, including defects and interruption.
- Fibers are scoped, so component unmounting cleans up in-flight operations.
- `mutate` composes directly inside larger Effect workflows.
- `mutateView` exposes call-specific progress as a View for React or Effect
consumers.
- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and
concurrency controls can be applied with normal Effect operators.
Mutation is therefore a small bridge: TanStack-style mutation state on one
side, and an ordinary, typed Effect program on the other.
+303
View File
@@ -0,0 +1,303 @@
---
sidebar_position: 3
title: Query
---
# Query
The Query module is Effect View's take on TanStack Query. It provides the same
kind of server-state workflow—reactive query keys, cached results, stale times,
background refreshes, window-focus refetching, and cache invalidation—but the
query function is an `Effect` and the observable state is a `View`.
If you already know TanStack Query, the main concepts translate directly:
| TanStack Query concept | Effect View equivalent |
| --- | --- |
| `QueryClient` | The `QueryClient` Effect service |
| `queryKey` | A reactive key supplied as a `View<K>` |
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `refetch` | `query.refresh` or `query.refreshView` |
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
The goal is familiar query behavior without leaving Effect's model. Query
functions keep their typed success, error, and service channels. They can use
services from the runtime, be composed with schema decoding and retry policies,
and are interrupted automatically when their scope ends or a new key supersedes
the current request.
## Provide a QueryClient
Queries share successful results through a `QueryClient`. Add its layer to the
application runtime once, alongside the services used by your query effects:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer({
defaultStaleTime: "30 seconds",
defaultRefreshOnWindowFocus: true,
cacheGcTime: "5 minutes",
})),
)
export const runtime = ReactRuntime.make(AppLive)
```
The client owns the cache and its cleanup lifecycle. Individual queries can
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
client defaults. Window-focus refresh also requires the optional
`@effect/platform-browser` package, as described under
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
## Create a reactive query
A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, `Query.service` checks the cache and starts
the query effect when necessary.
`Query.service` is an Effect constructor, not a hook. Create each query instance
once and keep it stable. In a component, the usual place is
`Component.useOnMount`; a query shared by multiple components can instead be
owned by an Effect service. Change the existing query's reactive key rather
than reconstructing the query during render.
```tsx
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Component, Lens, Query, View } from "effect-view"
const Post = Schema.Struct({
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
const PostView = Component.make("Post")(function* () {
const [postId, query] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const key = Lens.fromSubscriptionRef(
yield* SubscriptionRef.make(["post", 1 as number] as const),
)
const query = yield* Query.service({
key,
staleTime: "1 minute",
f: ([, id]) =>
HttpClient.HttpClient.pipe(
Effect.andThen((client) =>
client.get(`https://example.com/posts/${id}`),
),
Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
})
return [Lens.focusTupleAt(key, 1), query] as const
}),
)
const [state] = yield* View.useAll([query.state])
const [id, setId] = yield* Lens.useState(postId)
return (
<section>
<select
value={id}
onChange={(event) => setId(Number(event.currentTarget.value))}
>
<option value={1}>Post 1</option>
<option value={2}>Post 2</option>
</select>
<pre>{state.result._tag}</pre>
</section>
)
})
```
The tuple key plays the same role as `['post', id]` in TanStack Query. Keys use
Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality
semantics.
`Query.service` starts watching its key in the current scope. The
`Component.useOnMount` call above keeps both the query instance and its query
function identity stable for the component's lifetime.
## Render AsyncResult
`query.state` contains both the current key and an `AsyncResult`:
```ts
interface QueryState<K, A, E> {
readonly key: K
readonly result: AsyncResult.AsyncResult<A, E>
}
```
Subscribe with `View.useAll`, then match the result explicitly:
```tsx
import { AsyncResult } from "effect/unstable/reactivity"
const [state] = yield* View.useAll([query.state])
return AsyncResult.match(state.result, {
onInitial: ({ waiting }) =>
waiting ? <p>Loading...</p> : <p>Not loaded.</p>,
onFailure: ({ cause, previousSuccess, waiting }) => (
<section>
<p>Request failed: {cause.toString()}</p>
{previousSuccess._tag === "Some" && (
<p>Last post: {previousSuccess.value.value.title}</p>
)}
{waiting && <p>Trying again...</p>}
</section>
),
onSuccess: ({ value, waiting }) => (
<article>
{waiting && <small>Refreshing...</small>}
<h2>{value.title}</h2>
<p>{value.body}</p>
</article>
),
})
```
The `waiting` flag separates “do I have a result?” from “is work currently in
flight?”. During a background refresh, a successful result remains successful
and keeps its value while `waiting` becomes `true`. A failed refresh can retain
its `previousSuccess`. This avoids replacing useful content with an empty
loading screen every time data is refreshed.
Failures contain an Effect `Cause<E>`, preserving typed failures, defects, and
interruption information instead of flattening everything into an untyped
exception.
## Change the query key
The key is reactive state, so changing it is enough to fetch the corresponding
resource. There is no separate dependency array to keep synchronized:
```tsx
const [id, setId] = yield* Lens.useState(postId)
return (
<label>
Post
<select
value={id}
onChange={(event) => setId(Number(event.currentTarget.value))}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
)
```
When the key changes, the previous in-flight request is interrupted. The query
then reuses a fresh cached success or starts the effect for the new key.
## Refresh and invalidate
Refresh resolves the latest key again. A fresh cached result can satisfy it
immediately; a stale or missing result runs the query effect while preserving
previous data as background state. Invalidation removes cached data, so the
next fetch for that key must run the effect again.
```tsx
const runSync = yield* Component.useRunSync()
return (
<div>
<button onClick={() => runSync(query.refreshView)}>
Refresh current post
</button>
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
Invalidate current post
</button>
<button onClick={() => runSync(query.invalidateCache)}>
Invalidate all posts from this query
</button>
</div>
)
```
The methods come in two styles:
| Method | Behavior |
| --- | --- |
| `fetch(key)` | Fetch a specific key and wait for its final state. |
| `fetchView(key)` | Start fetching and immediately return a live state `View`. |
| `refresh` | Resolve the current key again and wait for its final state. |
| `refreshView` | Resolve the current key again and immediately return a live state `View`. |
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
| `invalidateCache` | Remove every cached success associated with this query function. |
The `*View` variants are convenient in synchronous UI callbacks: they return
after the scoped request has started, while the returned View and `query.state`
continue to publish progress. The non-View variants are useful in Effect
workflows that need to wait for the final success or failure state.
Invalidating does not itself refetch. Follow it with `refreshView`, change the
key, or allow a later fetch to repopulate the cache.
## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without
running the effect again. A stale entry remains available as previous data
while the query refreshes it.
`cacheGcTime` is configured on `QueryClient`. Entries that have not been
accessed are eventually removed after their stale period plus the configured
garbage-collection time.
By default, the client enables refresh on browser window focus. Set it globally
or override it for one query:
```tsx
const query = yield* Query.service({
key,
f: loadPost,
staleTime: "10 seconds",
refreshOnWindowFocus: false,
})
```
Window-focus refresh depends on the optional `@effect/platform-browser`
integration. Install it in browser applications that use this behavior:
```bash npm2yarn
npm install @effect/platform-browser@beta
```
When the package is available, focusing the window resolves the current key
again, subject to the same freshness check. Without it,
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
work normally. The integration is also ignored in non-browser environments.
## The Effect touch
The API emulates TanStack Query's server-state ergonomics, but query execution
remains ordinary Effect code:
- `f` has the full `Effect<A, E, R>` type, including required services.
- The query captures its Effect context when it is created, so callbacks do not
need to manually reconstruct dependencies.
- Effects can use `Schema`, retry schedules, tracing, logging, metrics,
cancellation, and any other Effect operator before becoming query state.
- Request fibers belong to the creation scope and are interrupted on unmount,
key replacement, or scope closure.
- Results are `View`s, so the same state can drive React through `View.useAll`
or participate in Effect streams and application logic outside React.
- Failures retain `Cause<E>` rather than losing Effect's error model.
`Query` is therefore best understood as a TanStack Query-shaped coordinator
for Effect programs: it handles when and whether to run them, while Effect
continues to describe what the work requires and how it behaves.
+297
View File
@@ -0,0 +1,297 @@
---
sidebar_position: 2
title: State Management
---
# State Management
`Lens` is the main type used for state management in `effect-view`.
A Lens is an effectful handle to a piece of state. It can read the current value,
subscribe to changes, and write updates back to the underlying source. A Lens
can point at a whole state object or focus on one nested field inside it.
`effect-view` re-exports the `Lens` and `View` modules from
[`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) for convenience. The
core data model and transformation APIs belong to `effect-lens`, so check the
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta) for the full Lens/View API.
The usual pattern is to create state with Effect primitives such as
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
`View.useAll`.
`View` is the read-only side of this model. Every Lens is also a View.
## Where To Store State
State can live pretty much anywhere as a `Lens` or `View`: in a
service, in a layer, in a component scope, or alongside plain React state. Pick
the owner based on who needs the state. Once you have a Lens/View
handle, pass it around however you like, including through React props.
If state is shared by multiple components or belongs to application logic, store
it in an Effect service:
```tsx
import { Context, Effect, Layer, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-view"
class CounterState extends Context.Service<
CounterState,
{ readonly count: Lens.Lens<number> }
>()("CounterState") {
static readonly layer = Layer.effect(
CounterState,
Effect.gen(function*() {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
return { count } as const
}),
)
}
const CounterValueView = Component.make("CounterValue")(function*() {
const state = yield* CounterState
const [count] = yield* View.useAll([state.count])
return <p>Count: {count}</p>
})
```
If state belongs to a single Effect View component instance, or to a shallow
hierarchy of subcomponents that receive it through props, create it with
`Component.useOnMount`:
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-view"
const LocalCounterView = Component.make("LocalCounter")(function*() {
const state = yield* Component.useOnMount(() =>
Effect.gen(function*() {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
return { count } as const
}),
)
const [count] = yield* View.useAll([state.count])
return <p>Count: {count}</p>
})
```
For simple UI state that is not shared and does not need Effect integration,
prefer regular React state. A local "show details" toggle is usually better as
`React.useState(false)` than as a Lens.
## View.useAll
A `View<A>` is reactive state with a current value and a stream of changes. Use
`View.useAll` whenever a component needs to bind View values into render output.
`Lens` is a `View`, so this is also the default way to read Lens values
from a component.
```tsx
import { Context, Effect, Layer, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-view"
class CounterState extends Context.Service<
CounterState,
{
readonly count: Lens.Lens<number>
readonly doubled: View.View<number>
}
>()("CounterState") {
static readonly layer = Layer.effect(
CounterState,
Effect.gen(function*() {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
const doubled = View.map(count, (n) => n * 2)
return { count, doubled } as const
}),
)
}
const CounterReadOnlyView = Component.make("CounterReadOnly")(
function* () {
const state = yield* CounterState
const [count, doubled] = yield* View.useAll([
state.count,
state.doubled,
])
return <p>Count: {count}, doubled: {doubled}</p>
},
)
```
`View.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
```tsx
const CounterControlsView = Component.make("CounterControls")(
function* () {
const state = yield* CounterState
const [count] = yield* View.useAll([state.count])
const increment = yield* Component.useCallbackSync(
() => Lens.update(state.count, (n) => n + 1),
[],
)
const reset = yield* Component.useCallbackSync(
() => Lens.set(state.count, 0),
[],
)
return (
<section>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={reset}>Reset</button>
</section>
)
},
)
```
## Lens.useState
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
setter, especially controlled inputs such as text fields, checkboxes, selects,
or third-party components with `value` / `onChange` props.
If a component only needs to display the value, prefer `View.useAll`.
`Lens.useState` is for places where reading and writing need to be wired
together in React's local-state shape.
```tsx
import { Context, Effect, Layer, SubscriptionRef } from "effect"
import { Component, Lens } from "effect-view"
class FormState extends Context.Service<
FormState,
{ readonly name: Lens.Lens<string> }
>()("FormState") {
static readonly layer = Layer.effect(
FormState,
Effect.gen(function*() {
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
return { name } as const
}),
)
}
const NameInputView = Component.make("NameInput")(function* () {
const state = yield* FormState
const [name, setName] = yield* Lens.useState(state.name)
return (
<input
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
)
})
```
`Lens.useState` returns the current value and a React-compatible setter. Calling
the setter writes through the Lens, so every other component subscribed to the
same Lens sees the update.
## Focused Lenses
Use focused Lenses when a component should work with one part of a larger state
object. A focused Lens is still a Lens, so it can be read with
`View.useAll` or used with `Lens.useState` when React needs a
read/write tuple.
```tsx
import { Context, Effect, Layer, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-view"
interface UserProfile {
readonly name: string
readonly email: string
readonly role: string
readonly contact: {
readonly address: {
readonly city: string
}
}
}
class ProfileState extends Context.Service<
ProfileState,
{ readonly profile: Lens.Lens<UserProfile> }
>()("ProfileState") {
static readonly layer = Layer.effect(
ProfileState,
Effect.gen(function*() {
const profile = Lens.fromSubscriptionRef(
yield* SubscriptionRef.make<UserProfile>({
name: "",
email: "",
role: "reader",
contact: {
address: {
city: "",
},
},
}),
)
return { profile } as const
}),
)
}
const ProfileNameView = Component.make("ProfileName")(function*() {
const state = yield* ProfileState
const [nameLens, roleLens, cityLens] = yield* Component.useOnMount(() =>
Effect.succeed([
Lens.focusObjectOn(state.profile, "name"),
Lens.focusObjectOn(state.profile, "role"),
state.profile.pipe(
Lens.focusObjectOn("contact"),
Lens.focusObjectOn("address"),
Lens.focusObjectOn("city"),
),
] as const),
)
const [name, setName] = yield* Lens.useState(nameLens)
const [role, city] = yield* View.useAll([roleLens, cityLens])
return (
<label>
Name
<input
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
<span>Role: {role}</span>
<span>City: {city}</span>
</label>
)
})
```
The service owns only the root `profile` Lens. The component chooses the fields
it needs, creates those focused Lenses once in `Component.useOnMount`, and
subscribes only to them. Updating `nameLens` through `Lens.useState` still
updates the parent `profile` Lens.
Lens focus helpers have a dual API. They can be called in data-first form, such
as `Lens.focusObjectOn(profile, "name")`, or in curried form with only the key
or index. The `cityLens` above uses the curried form with `pipe` to focus through
a deeper path without storing intermediate Lenses.
For focusing into nested state, deriving lenses, custom write behavior, and the
complete API, refer to the
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta).
+147
View File
@@ -0,0 +1,147 @@
import type * as Preset from "@docusaurus/preset-classic"
import type { Config } from "@docusaurus/types"
import { themes as prismThemes } from "prism-react-renderer"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
title: "Effect View",
tagline: "Write React function components with Effect",
favicon: "img/favicon.ico",
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
future: {
v4: true, // Improve compatibility with the upcoming Docusaurus v4
},
// Set the production url of your site here
url: "https://thiladev.github.io",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/effect-view/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "Thiladev", // Usually your GitHub org/user name.
projectName: "effect-view", // Usually your repo name.
onBrokenLinks: "throw",
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
{
docs: {
sidebarPath: "./sidebars.ts",
editUrl:
"https://github.com/Thiladev/effect-view/tree/main/packages/docs/",
lastVersion: "current",
versions: {
current: {
label: "effect-view v0",
},
"effect-fc-v0": {
label: "effect-fc v0",
path: "effect-fc/v0",
},
},
},
blog: false,
theme: {
customCss: "./src/css/custom.css",
},
} satisfies Preset.Options,
],
],
themeConfig: {
// Replace with your project's social card
colorMode: {
respectPrefersColorScheme: true,
},
navbar: {
title: "Effect View",
logo: {
alt: "Effect View logo",
src: "img/logo.svg",
},
items: [
{
type: "docSidebar",
sidebarId: "docsSidebar",
position: "left",
label: "Docs",
},
{
type: "docsVersionDropdown",
position: "right",
},
{
href: "https://www.npmjs.com/package/effect-view",
label: "npm",
position: "right",
},
{
href: "https://github.com/Thiladev/effect-view",
label: "GitHub",
position: "right",
},
],
},
footer: {
style: "dark",
links: [
{
title: "Docs",
items: [
{
label: "Getting Started",
to: "/docs/getting-started",
},
],
},
{
title: "Project",
items: [
{
label: "GitHub",
href: "https://github.com/Thiladev/effect-view",
},
{
label: "Example App",
href: "https://github.com/Thiladev/effect-view/tree/main/packages/example",
},
],
},
{
title: "Ecosystem",
items: [
{
label: "Effect",
href: "https://effect.website/",
},
{
label: "React",
href: "https://react.dev/",
},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} Effect View contributors. Built with Docusaurus.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
}
export default config
+54
View File
@@ -0,0 +1,54 @@
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint",
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build --out-dir dist",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve --dir dist",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"clean:cache": "rm -rf .turbo *.tsbuildinfo && docusaurus clear",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"dependencies": {
"@docusaurus/core": "^3.10.2",
"@docusaurus/faster": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"typescript": "~6.0.3"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const sidebars: SidebarsConfig = {
docsSidebar: [
"getting-started",
"state-management",
"async",
"query",
"mutation",
"forms",
],
}
export default sidebars
+37
View File
@@ -0,0 +1,37 @@
:root {
--ifm-color-primary: #0b6f74;
--ifm-color-primary-dark: #096469;
--ifm-color-primary-darker: #085e63;
--ifm-color-primary-darkest: #074d51;
--ifm-color-primary-light: #0d7a7f;
--ifm-color-primary-lighter: #0e8085;
--ifm-color-primary-lightest: #109096;
--ifm-background-color: #fffaf1;
--ifm-code-font-size: 95%;
--ifm-font-family-base:
"Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
--docusaurus-highlighted-code-line-bg: rgba(11, 111, 116, 0.1);
}
.button--primary {
--ifm-button-background-color: #0b6f74;
--ifm-button-border-color: #0b6f74;
}
.button--secondary {
--ifm-button-background-color: #f4a636;
--ifm-button-border-color: #f4a636;
--ifm-button-color: #1f2526;
}
[data-theme="dark"] {
--ifm-color-primary: #7ad6d4;
--ifm-color-primary-dark: #5cccca;
--ifm-color-primary-darker: #4cc6c4;
--ifm-color-primary-darkest: #34aaa8;
--ifm-color-primary-light: #98e0de;
--ifm-color-primary-lighter: #a8e6e4;
--ifm-color-primary-lightest: #d5f4f3;
--ifm-background-color: #111c1f;
--docusaurus-highlighted-code-line-bg: rgba(122, 214, 212, 0.18);
}
+678
View File
@@ -0,0 +1,678 @@
.page {
--home-ink: #102f31;
--home-muted: #506a6c;
--home-teal: #08777b;
--home-teal-bright: #16a3a1;
--home-amber: #e99526;
--home-paper: #fffdf8;
--home-card: rgba(255, 255, 255, 0.76);
--home-line: rgba(16, 47, 49, 0.12);
min-height: calc(100vh - var(--ifm-navbar-height));
overflow: hidden;
position: relative;
background:
radial-gradient(circle at 8% 5%, rgba(14, 145, 148, 0.2), transparent 28rem),
radial-gradient(circle at 92% 14%, rgba(240, 159, 48, 0.2), transparent 27rem),
linear-gradient(145deg, #f7f2e7 0%, #edf8f6 48%, #fff8ed 100%);
color: var(--home-ink);
}
.gridBackdrop {
background-image:
linear-gradient(rgba(16, 47, 49, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(16, 47, 49, 0.045) 1px, transparent 1px);
background-size: 48px 48px;
inset: 0;
mask-image: linear-gradient(to bottom, black, transparent 62%);
pointer-events: none;
position: absolute;
}
.hero {
align-items: center;
display: grid;
gap: clamp(3rem, 6vw, 6rem);
grid-template-columns: minmax(0, 0.88fr) minmax(560px, 1.12fr);
min-height: min(820px, calc(100vh - var(--ifm-navbar-height)));
padding-bottom: 6rem;
padding-top: 6rem;
position: relative;
z-index: 1;
}
.heroCopy {
max-width: 650px;
}
.eyebrow,
.sectionKicker {
align-items: center;
color: var(--home-teal);
display: flex;
font-size: 0.76rem;
font-weight: 850;
gap: 0.65rem;
letter-spacing: 0.18em;
margin: 0 0 1.35rem;
text-transform: uppercase;
}
.pulse {
background: var(--home-teal-bright);
border-radius: 50%;
box-shadow: 0 0 0 5px rgba(22, 163, 161, 0.14);
height: 0.55rem;
width: 0.55rem;
}
.hero h1 {
color: var(--home-ink);
font-size: clamp(3.4rem, 6.4vw, 6.4rem);
font-weight: 780;
letter-spacing: -0.075em;
line-height: 0.92;
margin: 0;
overflow-wrap: normal;
word-break: normal;
}
.hero h1 .unbreakable {
white-space: nowrap;
}
.hero h1 .heroAccent {
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
background-clip: text;
color: transparent;
display: inline;
-webkit-background-clip: text;
}
.lede {
color: var(--home-muted);
font-size: clamp(1.08rem, 1.55vw, 1.32rem);
line-height: 1.65;
margin: 2rem 0 0;
max-width: 630px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 2.3rem;
}
.primaryAction,
.secondaryAction,
.ctaAction {
align-items: center;
border-radius: 999px;
display: inline-flex;
font-weight: 750;
gap: 0.8rem;
justify-content: center;
padding: 0.88rem 1.35rem;
text-decoration: none !important;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.primaryAction {
background: var(--home-ink);
box-shadow: 0 12px 28px rgba(16, 47, 49, 0.2);
color: #f6fffd;
}
.primaryAction:hover {
box-shadow: 0 16px 36px rgba(16, 47, 49, 0.28);
color: #fff;
transform: translateY(-2px);
}
.secondaryAction {
background: rgba(255, 255, 255, 0.58);
border: 1px solid var(--home-line);
color: var(--home-ink);
}
.secondaryAction:hover {
background: rgba(255, 255, 255, 0.9);
color: var(--home-teal);
transform: translateY(-2px);
}
.install {
align-items: center;
color: var(--home-muted);
display: flex;
font-size: 0.84rem;
gap: 0.7rem;
margin-top: 1.4rem;
}
.install span {
color: var(--home-amber);
font-family: var(--ifm-font-family-monospace);
font-weight: 800;
}
.install code {
background: transparent;
border: 0;
color: inherit;
padding: 0;
}
.hotReload {
align-items: center;
color: var(--home-teal);
display: inline-flex;
font-size: 0.82rem;
font-weight: 750;
gap: 0.5rem;
margin-top: 0.75rem;
text-decoration: none !important;
}
.hotReload span {
color: var(--home-amber);
font-size: 1rem;
}
.hotReload:hover {
color: var(--home-teal-bright);
}
.codeStage {
min-width: 0;
perspective: 1200px;
position: relative;
}
.codeGlow {
background: linear-gradient(115deg, rgba(11, 133, 136, 0.48), rgba(238, 156, 40, 0.38));
filter: blur(55px);
inset: 8% 5%;
opacity: 0.55;
position: absolute;
}
.codeWindow {
background: #101b1e;
border: 1px solid rgba(166, 225, 220, 0.2);
border-radius: 1.25rem;
box-shadow:
0 34px 90px rgba(18, 49, 52, 0.3),
0 2px 0 rgba(255, 255, 255, 0.06) inset;
overflow: hidden;
position: relative;
transform: rotateY(-2deg) rotateX(1deg);
}
.windowBar {
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #b6c9c9;
display: grid;
font-family: var(--ifm-font-family-monospace);
font-size: 0.73rem;
grid-template-columns: 1fr auto 1fr;
padding: 0.78rem 1rem;
}
.windowDots {
display: flex;
gap: 0.38rem;
}
.windowDots span {
background: #4f6d6e;
border-radius: 50%;
height: 0.55rem;
width: 0.55rem;
}
.windowDots span:first-child {
background: #ef8d5b;
}
.windowDots span:nth-child(2) {
background: #e9bc50;
}
.windowDots span:last-child {
background: #65bd92;
}
.windowStatus {
color: #74d2cd;
justify-self: end;
}
.codeWindow :global(.theme-code-block) {
border-radius: 0;
box-shadow: none;
margin: 0;
}
.codeWindow :global(.theme-code-block pre) {
background: #101b1e !important;
color: #d7e5e4 !important;
font-size: 0.78rem;
line-height: 1.62;
max-height: none;
padding: 1.45rem 1.6rem !important;
}
.codeWindow :global(.token-line),
.codeWindow :global(.token.plain),
.codeWindow :global(.token.imports),
.codeWindow :global(.token.maybe-class-name),
.codeWindow :global(.token.punctuation),
.codeWindow :global(.token.operator),
.codeWindow :global(.token.plain-text),
.codeWindow :global(.token.property-access) {
color: #d7e5e4 !important;
}
.codeWindow :global(.token.keyword) {
color: #c792ea !important;
}
.codeWindow :global(.token.string) {
color: #c3e88d !important;
}
.codeWindow :global(.token.function),
.codeWindow :global(.token.method) {
color: #82aaff !important;
}
.codeWindow :global(.token.tag) {
color: #f07178 !important;
}
.codeWindow :global(.token.builtin) {
color: #ffcb6b !important;
}
.codeLegend {
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: #a9bcbc;
display: grid;
font-size: 0.7rem;
grid-template-columns: repeat(3, 1fr);
}
.codeLegend span {
padding: 0.8rem 0.9rem;
text-align: center;
}
.codeLegend span + span {
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.codeLegend b {
color: #e8a94b;
font-family: var(--ifm-font-family-monospace);
margin-right: 0.25rem;
}
.featuresSection {
padding-bottom: 7rem;
padding-top: 4rem;
position: relative;
z-index: 1;
}
.sectionHeading {
display: grid;
gap: 0 3rem;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
margin-bottom: 2.8rem;
}
.sectionHeading .sectionKicker {
grid-column: 1 / -1;
}
.sectionHeading h2,
.cta h2 {
color: var(--home-ink);
font-size: clamp(2.25rem, 4vw, 4rem);
letter-spacing: -0.055em;
line-height: 1;
margin: 0;
}
.sectionHeading > p:last-child {
align-self: end;
color: var(--home-muted);
font-size: 1.03rem;
line-height: 1.65;
margin: 0;
}
.featureGrid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.featureCard {
background: var(--home-card);
border: 1px solid var(--home-line);
border-radius: 1.35rem;
color: inherit;
display: flex;
flex-direction: column;
min-height: 310px;
overflow: hidden;
padding: 1.45rem;
position: relative;
text-decoration: none !important;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.featureCard::before {
background: linear-gradient(90deg, var(--home-teal-bright), var(--home-amber));
content: "";
height: 3px;
left: 1.45rem;
opacity: 0;
position: absolute;
right: 1.45rem;
top: 0;
transform: scaleX(0.5);
transition: opacity 180ms ease, transform 180ms ease;
}
.featureCard:hover {
border-color: rgba(8, 119, 123, 0.35);
box-shadow: 0 24px 60px rgba(28, 65, 66, 0.12);
color: inherit;
transform: translateY(-5px);
}
.featureCard:hover::before {
opacity: 1;
transform: scaleX(1);
}
.cardTopline {
align-items: center;
display: flex;
justify-content: space-between;
}
.featureIcon {
align-items: center;
background: rgba(8, 119, 123, 0.1);
border: 1px solid rgba(8, 119, 123, 0.12);
border-radius: 0.8rem;
color: var(--home-teal);
display: flex;
font-family: var(--ifm-font-family-monospace);
font-size: 1rem;
font-weight: 850;
height: 2.7rem;
justify-content: center;
width: 2.7rem;
}
.cardIndex {
color: rgba(16, 47, 49, 0.32);
font-family: var(--ifm-font-family-monospace);
font-size: 0.72rem;
}
.featureCard h3 {
color: var(--home-ink);
font-size: 1.25rem;
letter-spacing: -0.025em;
margin: 2rem 0 0.7rem;
}
.featureCard > p {
color: var(--home-muted);
font-size: 0.94rem;
line-height: 1.65;
margin: 0;
}
.cardFooter {
align-items: center;
display: flex;
justify-content: space-between;
margin-top: auto;
padding-top: 1.5rem;
}
.cardFooter code {
background: rgba(8, 119, 123, 0.08);
border: 0;
color: var(--home-teal);
font-size: 0.7rem;
padding: 0.35rem 0.5rem;
}
.cardFooter > span {
color: var(--home-amber);
font-size: 1.1rem;
transition: transform 180ms ease;
}
.featureCard:hover .cardFooter > span {
transform: translate(3px, -3px);
}
.ctaWrap {
padding-bottom: 7rem;
position: relative;
z-index: 1;
}
.cta {
align-items: center;
background:
radial-gradient(circle at 90% 20%, rgba(233, 149, 38, 0.22), transparent 20rem),
linear-gradient(125deg, #12383a, #10292c);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.75rem;
box-shadow: 0 28px 80px rgba(16, 47, 49, 0.2);
display: flex;
gap: 3rem;
justify-content: space-between;
overflow: hidden;
padding: clamp(2rem, 5vw, 4rem);
}
.cta .sectionKicker {
color: #79d5d1;
}
.cta h2 {
color: #f7fffd;
}
.cta p:last-child {
color: #b9cccc;
line-height: 1.65;
margin: 1rem 0 0;
max-width: 650px;
}
.ctaAction {
background: #f0a13b;
color: #162d2e;
flex: 0 0 auto;
}
.ctaAction:hover {
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.24);
color: #102526;
transform: translateY(-2px);
}
:global([data-theme="dark"]) .page {
--home-ink: #e9f7f5;
--home-muted: #a9bfbd;
--home-teal: #79d5d1;
--home-teal-bright: #5bc8c5;
--home-amber: #f1ad4e;
--home-card: rgba(20, 42, 45, 0.76);
--home-line: rgba(174, 221, 218, 0.14);
background:
radial-gradient(circle at 8% 5%, rgba(33, 154, 154, 0.17), transparent 28rem),
radial-gradient(circle at 92% 14%, rgba(209, 130, 31, 0.13), transparent 27rem),
linear-gradient(145deg, #0f1b1e 0%, #102426 48%, #161e1e 100%);
}
:global([data-theme="dark"]) .gridBackdrop {
background-image:
linear-gradient(rgba(174, 221, 218, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(174, 221, 218, 0.04) 1px, transparent 1px);
}
:global([data-theme="dark"]) .secondaryAction {
background: rgba(19, 43, 46, 0.75);
}
:global([data-theme="dark"]) .secondaryAction:hover {
background: rgba(27, 57, 60, 0.95);
}
:global([data-theme="dark"]) .primaryAction {
background: #dff8f5;
color: #123638;
}
:global([data-theme="dark"]) .primaryAction:hover {
color: #0b282a;
}
:global([data-theme="dark"]) .cardIndex {
color: rgba(220, 242, 239, 0.35);
}
@media screen and (max-width: 1180px) {
.hero {
gap: 3rem;
grid-template-columns: minmax(0, 0.85fr) minmax(500px, 1.15fr);
}
.hero h1 {
font-size: clamp(3.2rem, 6vw, 5rem);
}
}
@media screen and (max-width: 996px) {
.hero {
grid-template-columns: minmax(0, 1fr);
padding-bottom: 4rem;
padding-top: 4.5rem;
}
.heroCopy,
.codeStage,
.codeWindow,
.codeWindow :global(.theme-code-block) {
min-width: 0;
width: 100%;
}
.heroCopy {
max-width: 760px;
}
.codeStage {
margin: 0 auto;
max-width: 760px;
width: 100%;
}
.codeWindow {
transform: none;
}
.featureGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media screen and (max-width: 700px) {
.hero {
box-sizing: border-box;
margin-left: 0;
margin-right: 0;
max-width: 100vw;
padding-left: 1.15rem;
padding-right: 1.15rem;
width: 100vw;
}
.heroCopy,
.codeStage {
max-width: calc(100vw - 2.3rem);
}
.hero h1 {
font-size: clamp(3rem, 15vw, 4.3rem);
}
.sectionHeading {
grid-template-columns: 1fr;
}
.sectionHeading > p:last-child {
margin-top: 1.25rem;
}
.featureGrid {
grid-template-columns: 1fr;
}
.featureCard {
min-height: 275px;
}
.codeLegend {
grid-template-columns: 1fr;
}
.codeLegend span + span {
border-left: 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.windowStatus {
display: none;
}
.windowBar {
grid-template-columns: 1fr auto;
}
.codeWindow :global(.theme-code-block pre) {
font-size: 0.69rem;
overflow-x: auto;
padding: 1.1rem !important;
}
.cta {
align-items: flex-start;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCard::before,
.cardFooter > span,
.primaryAction,
.secondaryAction,
.ctaAction {
transition: none;
}
}
+212
View File
@@ -0,0 +1,212 @@
import Link from "@docusaurus/Link"
import CodeBlock from "@theme/CodeBlock"
import Layout from "@theme/Layout"
import clsx from "clsx"
import type { ReactNode } from "react"
import styles from "./index.module.css"
const componentExample = `import { Effect } from "effect"
import { Async, Component } from "effect-view"
import { Users } from "./services"
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { userId: string }) {
const users = yield* Users
const user = yield* Component.useOnChange(
() => users.find(userId),
[userId],
)
yield* Component.useReactEffect(
() => Effect.forkScoped(users.watch(userId)),
[userId],
)
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
</article>
)
},
).pipe(Async.async)`
const features = [
{
icon: "fx",
title: "Components are Effect programs",
description:
"Yield Effects and services directly from a component body, then return ordinary JSX. Types track every dependency all the way to the runtime boundary.",
tag: "Component.make",
to: "/docs/getting-started#write-your-first-component",
},
{
icon: "R",
title: "One typed runtime",
description:
"Build your application Layer once. Components access HTTP clients, repositories, configuration, tracing, and your own services without React context plumbing.",
tag: "ReactRuntime",
to: "/docs/getting-started#create-the-runtime",
},
{
icon: "S",
title: "Scopes follow React",
description:
"Every component owns an Effect Scope. Resources, subscriptions, and fibers are finalized when their component or dependency lifecycle ends.",
tag: "Scope.Scope",
to: "/docs/getting-started#the-component-root-scope",
},
{
icon: "↔",
title: "Reactive state without a new universe",
description:
"Lens and View connect Effect state to React. Focus large models into small writable values and subscribe only where rendering needs them.",
tag: "Lens + View",
to: "/docs/state-management",
},
{
icon: "Q",
title: "TanStack-shaped server state",
description:
"Reactive keys, caching, stale times, background refresh, mutations, and invalidation—with typed Effect services, Causes, and scoped fibers underneath.",
tag: "Query + Mutation",
to: "/docs/query",
},
{
icon: "Σ",
title: "Forms driven by Schema",
description:
"Keep input-friendly encoded values while application code receives validated, decoded types. Focus one root into reusable field-sized subforms.",
tag: "MutationForm + LensForm",
to: "/docs/forms",
},
] as const
export default function Home(): ReactNode {
return (
<Layout
title="Effect View"
description="Write React function components as typed Effect programs"
>
<main className={styles.page}>
<div className={styles.gridBackdrop} aria-hidden="true" />
<section className={clsx("container", styles.hero)}>
<div className={styles.heroCopy}>
<div className={styles.eyebrow}>
<span className={styles.pulse} />
Effect View for React 19
</div>
<h1>
React <span className={styles.unbreakable}>components,</span>
<span className={styles.heroAccent}> powered by Effect.</span>
</h1>
<p className={styles.lede}>
Bring typed services, scoped resources, reactive state, server
queries, and schema-driven forms into React without hiding either
framework.
</p>
<div className={styles.actions}>
<Link className={styles.primaryAction} to="/docs/getting-started">
Start building
<span aria-hidden="true"></span>
</Link>
<Link
className={styles.secondaryAction}
to="https://github.com/Thiladev/effect-view"
>
View on GitHub
</Link>
</div>
<div className={styles.install}>
<span aria-hidden="true">$</span>
<code>npm install effect-view effect@beta</code>
</div>
<Link
className={styles.hotReload}
to="/docs/getting-started#set-up-hot-reloading-with-vite"
>
<span aria-hidden="true"></span>
Hot reload with Vite Fast Refresh
</Link>
</div>
<div className={styles.codeStage}>
<div className={styles.codeGlow} aria-hidden="true" />
<div className={styles.codeWindow}>
<div className={styles.windowBar}>
<div className={styles.windowDots} aria-hidden="true">
<span />
<span />
<span />
</div>
<span>UserCard.tsx</span>
<span className={styles.windowStatus}>Effect + JSX</span>
</div>
<CodeBlock language="tsx">{componentExample}</CodeBlock>
<div className={styles.codeLegend}>
<span><b>01</b> Yield services</span>
<span><b>02</b> Own the lifecycle</span>
<span><b>03</b> Return JSX</span>
</div>
</div>
</div>
</section>
<section className={clsx("container", styles.featuresSection)}>
<div className={styles.sectionHeading}>
<p className={styles.sectionKicker}>Why Effect View</p>
<h2>One model from render to resources.</h2>
<p>
Effect View adds the pieces React deliberately leaves open while
preserving the React component model you already know.
</p>
</div>
<div className={styles.featureGrid}>
{features.map((feature, index) => (
<Link
className={styles.featureCard}
to={feature.to}
key={feature.title}
>
<div className={styles.cardTopline}>
<span className={styles.featureIcon}>{feature.icon}</span>
<span className={styles.cardIndex}>
{String(index + 1).padStart(2, "0")}
</span>
</div>
<h3>{feature.title}</h3>
<p>{feature.description}</p>
<div className={styles.cardFooter}>
<code>{feature.tag}</code>
<span aria-hidden="true"></span>
</div>
</Link>
))}
</div>
</section>
<section className={clsx("container", styles.ctaWrap)}>
<div className={styles.cta}>
<div>
<p className={styles.sectionKicker}>Ready when React is</p>
<h2>Start with one Effect component.</h2>
<p>
Add a runtime, cross the React boundary once, and grow into the
rest of the toolkit only when you need it.
</p>
</div>
<Link className={styles.ctaAction} to="/docs/getting-started">
Read the guide <span aria-hidden="true"></span>
</Link>
</div>
</section>
</main>
</Layout>
)
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+47
View File
@@ -0,0 +1,47 @@
<svg
width="128"
height="128"
viewBox="0 0 128 128"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Effect View</title>
<defs>
<linearGradient id="surface" x1="14" y1="10" x2="116" y2="122" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A3A1" />
<stop offset="0.58" stop-color="#08777B" />
<stop offset="1" stop-color="#102F31" />
</linearGradient>
<radialGradient id="warmth" cx="0" cy="0" r="1" gradientTransform="translate(112 111) rotate(-135) scale(65)">
<stop stop-color="#F0A13B" stop-opacity="0.85" />
<stop offset="1" stop-color="#F0A13B" stop-opacity="0" />
</radialGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="3" stdDeviation="2.5" flood-color="#062426" flood-opacity="0.22" />
</filter>
<clipPath id="tile">
<rect width="128" height="128" rx="30" />
</clipPath>
</defs>
<g clip-path="url(#tile)">
<rect width="128" height="128" fill="url(#surface)" />
<circle cx="112" cy="111" r="65" fill="url(#warmth)" />
<path d="M-8 30L55 -4H128V10L-8 83V30Z" fill="white" fill-opacity="0.055" />
</g>
<rect x="1" y="1" width="126" height="126" rx="29" stroke="white" stroke-opacity="0.14" stroke-width="2" />
<g transform="translate(24 24) scale(2.5)" fill="white" filter="url(#shadow)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.8022 24.317C30.2747 24.05 30.4361 23.4582 30.1636 22.9953C29.891 22.5329 29.2873 22.3741 28.8148 22.6411L15.9211 29.9362L3.07463 22.6683C2.60281 22.4012 1.999 22.5594 1.72597 23.0225C1.45347 23.4854 1.61541 24.077 2.08741 24.3441L15.3897 31.8698C15.5053 31.9353 15.6327 31.9771 15.7645 31.9929C15.8963 32.0087 16.0299 31.9981 16.1576 31.9617C16.278 31.9433 16.3941 31.9031 16.5002 31.8431L29.8022 24.317Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.1298 16.6012C31.1974 16.1929 31.0061 15.7675 30.6177 15.5488L16.555 7.63105C16.4443 7.56873 16.3234 7.52682 16.198 7.50732C16.0631 7.46888 15.922 7.45758 15.7827 7.47405C15.6434 7.49053 15.5088 7.53446 15.3865 7.60332L1.32289 15.5214C0.913972 15.7518 0.723686 16.2117 0.824499 16.6391C0.780205 16.9913 0.91787 17.3598 1.32768 17.5916L15.3904 25.5478C15.5127 25.6169 15.6475 25.661 15.7869 25.6776C15.9263 25.6942 16.0675 25.6829 16.2026 25.6445C16.3297 25.6253 16.4522 25.583 16.5642 25.5197L30.6275 17.563C31.0408 17.329 31.1776 16.9562 31.1298 16.6012ZM28.2266 16.5591L15.9459 9.64453L3.67206 16.5554L15.9528 23.5034L28.2266 16.5591Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3429 10.6097C31.8677 10.3131 32.0476 9.65608 31.7442 9.14178C31.4416 8.62819 30.7712 8.45201 30.2464 8.74854L15.9269 16.8501L1.66063 8.77876C1.13584 8.48152 0.465408 8.65787 0.162793 9.172C-0.14053 9.68541 0.0391253 10.3432 0.564095 10.6397L15.337 18.9976C15.4654 19.0702 15.607 19.1165 15.7534 19.1339C15.8998 19.1514 16.0482 19.1395 16.19 19.0991C16.3236 19.0791 16.4524 19.0347 16.5701 18.9681L31.3429 10.6097Z" />
<path d="M2.7403 9.6795L15.8991 1.62024L29.0577 9.67879L15.8989 17.2013L2.7403 9.6795Z" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3255 8.49027C31.8513 8.78627 32.0333 9.44279 31.7325 9.95692C31.4307 10.4707 30.7603 10.6474 30.2344 10.3514L15.9128 2.28787L1.64317 10.3224C1.11731 10.6184 0.44688 10.4415 0.145328 9.92794C-0.15587 9.41381 0.0262664 8.75729 0.55159 8.46129L15.325 0.143725C15.4534 0.0713274 15.5949 0.0251207 15.7412 0.00776107C15.8875 -0.00959854 16.0358 0.00223134 16.1775 0.0425706C16.3093 0.0631109 16.4364 0.107185 16.5526 0.172702L31.3255 8.49027Z" />
</g>
<g filter="url(#shadow)">
<path d="M43 61.5C48.8 54.1 55.8 50.5 64 50.5C72.2 50.5 79.2 54.1 85 61.5C79.2 68.9 72.2 72.5 64 72.5C55.8 72.5 48.8 68.9 43 61.5Z" fill="#102F31" />
<circle cx="64" cy="61.5" r="7.5" fill="#F0A13B" />
<circle cx="66.5" cy="59" r="2.2" fill="white" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+9
View File
@@ -0,0 +1,9 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0"
},
"exclude": [".docusaurus", "build"]
}
@@ -0,0 +1,168 @@
---
sidebar_position: 3
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/Subscribables for form values, validation issues, and commit state, so
you can build the UI with whatever components your app already uses.
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`:
- `encodedValue`: a `Lens` containing the raw input value.
- `value`: a `Subscribable` containing the decoded value as an `Option`.
- `issues`: a `Subscribable` containing schema validation issues for that field.
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
state.
There are two root form flavors:
- `SubmittableForm`: owns local encoded form state, validates it, and submits a
decoded value through a mutation.
- `SynchronizedForm`: 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
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
The core form model is Effect code and can technically be used outside
Effect-FC. Effect-FC adds the React-facing hooks, such as `Form.useInput`, that
make those forms convenient to bind to components.
## Field Inputs
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.
```tsx
import { Component, Form, Subscribable } from "effect-fc"
const TextInputView = Component.make("TextInput")(
function* (props: {
readonly form: Form.Form<readonly PropertyKey[], string, string>
readonly label: string
}) {
const input = yield* Form.useInput(props.form, {
debounce: "250 millis",
})
const [issues] = yield* Subscribable.useAll([props.form.issues])
return (
<label>
{props.label}
<input
value={input.value}
onChange={(event) => input.setValue(event.currentTarget.value)}
/>
{issues.map((issue) => (
<small key={issue.path.join(".")}>
{issue.message}
</small>
))}
</label>
)
},
)
```
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.
## Submittable Forms
Use `SubmittableForm` when the user edits local encoded form state, validates it
with a schema, and then submits a decoded value.
```tsx
import { Effect, Schema } from "effect"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
import { TextInputView } from "./TextInputView"
const RegisterSchema = Schema.Struct({
email: Schema.String,
password: Schema.String,
})
class RegisterFormState extends Effect.Service<RegisterFormState>()(
"RegisterFormState",
{
scoped: Effect.gen(function* () {
const form = yield* SubmittableForm.service({
schema: RegisterSchema,
initialEncodedValue: {
email: "",
password: "",
},
f: ([value]) =>
Effect.log(`Registering ${value.email}`),
})
return {
form,
email: Form.focusObjectOn(form, "email"),
password: Form.focusObjectOn(form, "password"),
} as const
}),
},
) {}
const RegisterFormView = Component.make("RegisterForm")(
function* () {
const state = yield* RegisterFormState
const [canCommit, isCommitting] = yield* Subscribable.useAll([
state.form.canCommit,
state.form.isCommitting,
])
const runPromise = yield* Component.useRunPromise()
const TextInput = yield* TextInputView.use
return (
<form
onSubmit={(event) => {
event.preventDefault()
void runPromise(state.form.submit)
}}
>
<TextInput label="Email" form={state.email} />
<TextInput label="Password" form={state.password} />
<button disabled={!canCommit}>
{isCommitting ? "Submitting..." : "Submit"}
</button>
</form>
)
},
)
```
`SubmittableForm.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/Subscribables.
## Synchronized Forms
Use `SynchronizedForm` 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 `SubmittableForm` for "submit this draft" flows, and
`SynchronizedForm` 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.
@@ -0,0 +1,415 @@
---
sidebar_position: 1
title: Getting Started
---
# Getting Started
`effect-fc` lets React components be written as Effect programs. Inside a
component body you can yield services, run Effects, subscribe to Effect-powered
state, and still export a normal React function component at the edge of your
app.
## Install
Install `effect-fc` alongside `effect` and React 19.2 or newer:
```bash npm2yarn
npm install effect-fc effect react
```
```bash npm2yarn
npm install --save-dev @types/react
```
`effect-fc` is not opinionated about the React platform. Use it with web,
native, custom renderers, or any environment where React components can run.
Then install the platform-specific React packages for your target.
For web apps, install React DOM:
```bash npm2yarn
npm install react-dom
```
```bash npm2yarn
npm install --save-dev @types/react-dom
```
## Create A Runtime
An Effect-FC app needs an Effect runtime. Build one from the services your UI
needs, then share it with React through `ReactRuntime.Provider`.
For an empty app, `Layer.empty` is enough:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { ReactRuntime } from "effect-fc"
export const runtime = ReactRuntime.make(Layer.empty)
```
As your app grows, add services to the layer:
```tsx title="src/runtime.ts"
import { FetchHttpClient } from "@effect/platform"
import { Layer } from "effect"
import { ReactRuntime } from "effect-fc"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
```
## Provide The Runtime
At the React root, wrap your app with `ReactRuntime.Provider`:
```tsx title="src/main.tsx"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ReactRuntime } from "effect-fc"
import { App } from "./App"
import { runtime } from "./runtime"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
```
`ReactRuntime.Provider` also works with routers. Keep it above your router
provider so route components can be converted with the same runtime context.
## Write Your First Component
Use `Component.make` when you want automatic tracing spans, or
`Component.makeUntraced` when you only want the component behavior. This creates
an Effect-FC component, not a plain React component yet.
The Effect run during render must be synchronous. Effect-FC follows React's
render model, so component bodies should produce JSX without waiting on async
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
async work that happens outside render.
```tsx title="src/HelloView.tsx"
import { Effect } from "effect"
import { Component } from "effect-fc"
export const HelloView = Component.make("HelloView")(function* (props: {
readonly name: string
}) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
})
```
At this point `HelloView` can yield Effects, services, and scoped lifecycle
work, but React cannot render it directly.
## Apply A Runtime
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
to become a regular React function component.
```tsx title="src/Hello.tsx"
import { Component } from "effect-fc"
import { HelloView } from "./HelloView"
import { runtime } from "./runtime"
export const Hello = HelloView.pipe(
Component.withRuntime(runtime.context),
)
```
`Hello` is now a normal React component:
```tsx title="src/App.tsx"
import { Hello } from "./Hello"
export function App() {
return <Hello name="Effect" />
}
```
## Use Effect-FC Components Together
Inside an Effect-FC component, other Effect-FC components are available through
their `.use` effect. Yield `.use` to get a React component for the current
runtime context, then render it like JSX.
```tsx title="src/GreetingCardView.tsx"
import { Component } from "effect-fc"
import { HelloView } from "./HelloView"
export const GreetingCardView = Component.make("GreetingCard")(
function* () {
const Hello = yield* HelloView.use
return (
<section>
<Hello name="Effect" />
<p>This component is still running inside Effect-FC.</p>
</section>
)
},
)
```
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
normal React component again:
```tsx title="src/GreetingCard.tsx"
import { Component } from "effect-fc"
import { GreetingCardView } from "./GreetingCardView"
import { runtime } from "./runtime"
export const GreetingCard = GreetingCardView.pipe(
Component.withRuntime(runtime.context),
)
```
## Component Lifecycle
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
`Scope.Scope` can use that component scope to register finalizers, fork scoped
work, or acquire scoped resources.
The component scope is created when React mounts the component and closes when
React unmounts it. When the scope closes, Effect runs the finalizers registered
inside that scope.
Finalizers are forked when the scope closes, so cleanup logic can run
asynchronous Effects even though the component body itself must stay
synchronous.
```tsx title="src/MountedMessageView.tsx"
import { Console, Effect } from "effect"
import { Component } from "effect-fc"
export const MountedMessageView = Component.make("MountedMessage")(
function* () {
const message = yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Console.log("MountedMessage mounted")
yield* Effect.addFinalizer(() =>
Console.log("MountedMessage unmounted"),
)
return "This value was loaded on mount."
}),
)
return <p>{message}</p>
},
)
```
Use `Component.useOnMount` when the component needs a value produced by an
Effect during its first render. The value is then cached for the component
instance. You can also use it to set up scoped component logic, subscriptions,
or resources that should live for the lifetime of that component instance.
Use `Component.useOnChange` when render needs the value computed by
scoped work that depends on changing inputs. When a dependency changes, React
re-renders the component and the hook computes the next value during that
render.
```tsx
import { Console, Effect } from "effect"
import { Component } from "effect-fc"
const UserPanelView = Component.make("UserPanel")(
function* (props: { readonly userId: string }) {
const label = yield* Component.useOnChange(
() =>
Effect.gen(function* () {
yield* Console.log(`Preparing view for ${props.userId}`)
yield* Effect.addFinalizer(() =>
Console.log(`Cleaning up ${props.userId}`),
)
return `Viewing user ${props.userId}`
}),
[props.userId],
)
return <p>{label}</p>
},
)
```
In this example, each `userId` gets its own scope. When `userId` changes,
Effect-FC closes the previous scope, runs its finalizers, and creates a new
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
component's root scope directly. It creates and provides its own scope for that
dependency window. Some other Effect-FC hooks follow the same pattern when they
need a lifecycle that is narrower than the whole component instance.
## Useful Effect-FC Hooks
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
them inside the component body.
The most common Effect-FC hooks are:
- `Component.useOnMount`: run an Effect once during the component's first render
after mount, and return its value. The Effect must be synchronous.
```tsx
const initialData = yield* Component.useOnMount(() => loadInitialData)
```
- `Component.useOnChange`: when a dependency changes, React re-renders the
component and the Effect is run again inside the component body. It returns
the latest value, so the Effect must be synchronous too.
```tsx
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
```
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
that do not need to compute render output or trigger a re-render. The Effect
must be synchronous and can register scoped finalizers.
```tsx
yield* Component.useReactEffect(
() => Effect.forkScoped(subscribeToUser(id)),
[id],
)
```
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
finalizers.
```tsx
yield* Component.useReactLayoutEffect(() => measure(ref), [])
```
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
event handlers.
```tsx
const runPromise = yield* Component.useRunPromise()
return (
<button onClick={() => void runPromise(saveUser)}>
Save
</button>
)
```
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
React callbacks.
```tsx
const save = yield* Component.useCallbackPromise(
(user: User) => saveUser(user),
[],
)
return <button onClick={() => void save(user)}>Save</button>
```
## Use React Normally
An Effect-FC component is still a React function component. Anything you can do
in a regular React component can also be done in an Effect-FC component,
including React hooks, refs, event handlers, context, and JSX composition.
```tsx
import { Component } from "effect-fc"
import * as React from "react"
const CounterView = Component.make("Counter")(function* () {
const [count, setCount] = React.useState(0)
const buttonRef = React.useRef<HTMLButtonElement>(null)
return (
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
})
```
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
when the component needs Effect services, scopes, resources, or Effect-powered
callbacks.
## Use Tags
Components can yield Effect tags directly in the component body. There is no
need to memoize the service yourself; just yield the tag wherever the component
needs it.
```tsx title="src/Greeting.tsx"
const GreetingView = Component.make("Greeting")(function* (props: {
readonly name: string
}) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
})
```
Service values in the runtime context are reactive by default. If a provided
service instance changes, Effect-FC unmounts and mounts again the components
that depend on that context, so they read the new service and restart their
scoped lifecycle with the new environment. For services that should not trigger
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
[...] })`.
## Provide Services To A Subtree
Use `Component.useContextFromLayer` when an Effect-FC component should provide
extra services to another Effect-FC component. It turns a `Layer` into a runtime
context that can be provided to `.use`.
```tsx title="src/GreetingPageView.tsx"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { GreetingView } from "./Greeting"
import { GreetingService } from "./services"
const GreetingLive = GreetingService.Default({
greet: (name: string) => `Welcome, ${name}`,
})
export const GreetingPageView = Component.make("GreetingPage")(function* () {
const context = yield* Component.useContextFromLayer(GreetingLive)
const Greeting = yield* Effect.provide(GreetingView.use, context)
return <Greeting name="Effect" />
})
```
The layer passed to `useContextFromLayer` should be stable. If a new layer value
is created on every render, Effect-FC has to build a new context, which can
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
outside the component, or memoize layers that depend on React props or state.
Layers built with `useContextFromLayer` are scoped to the component that builds
them. That means service construction can register scoped logic, acquire
resources, fork scoped fibers, and add finalizers that are tied to that
component's lifecycle.
## Where To Go Next
Once the runtime and component boundary are in place, the rest of the library
builds on the same idea:
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
change.
- `Lens` connects React state and Effect `SubscriptionRef` values.
- `Query` and `Mutation` model async data and user-triggered operations.
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
forms.
The important pattern is small and repeatable: write Effect-FC components inside
the runtime, then use `Component.withRuntime` at React boundaries.
@@ -0,0 +1,245 @@
---
sidebar_position: 2
title: State Management
---
# State Management
`Lens` is the main type used for state management in `effect-fc`.
A Lens is an effectful handle to a piece of state. It can read the current value,
subscribe to changes, and write updates back to the underlying source. A Lens
can point at a whole state object or focus on one nested field inside it.
The usual pattern is to create state with Effect primitives such as
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
`Subscribable.useAll`.
`Subscribable` is the read-only side of this model. Every Lens is also a
Subscribable.
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
core data model and transformation APIs belong to `effect-lens`, so check the
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/Subscribable API.
## Where To Store State
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
service, in a layer, in a component scope, or alongside plain React state. Pick
the owner based on who needs the state. Once you have a Lens/Subscribable
handle, pass it around however you like, including through React props.
If state is shared by multiple components or belongs to application logic, store
it in an Effect service:
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, Subscribable } from "effect-fc"
class CounterState extends Effect.Service<CounterState>()("CounterState", {
effect: Effect.gen(function* () {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
return { count } as const
}),
}) {}
const CounterValueView = Component.make("CounterValue")(function* () {
const state = yield* CounterState
const [count] = yield* Subscribable.useAll([state.count])
return <p>Count: {count}</p>
})
```
If state belongs to a single Effect-FC component instance, or to a shallow
hierarchy of subcomponents that receive it through props, create it with
`Component.useOnMount`:
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, Subscribable } from "effect-fc"
const LocalCounterView = Component.make("LocalCounter")(function* () {
const state = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
return { count } as const
}),
)
const [count] = yield* Subscribable.useAll([state.count])
return <p>Count: {count}</p>
})
```
For simple UI state that is not shared and does not need Effect integration,
prefer regular React state. A local "show details" toggle is usually better as
`React.useState(false)` than as a Lens.
## Subscribable.useAll
A `Subscribable<A>` is reactive state with a current value and a stream of
changes. Use `Subscribable.useAll` whenever a component needs to bind
subscribable values into render output.
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
from a component.
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, Subscribable } from "effect-fc"
class CounterState extends Effect.Service<CounterState>()("CounterState", {
effect: Effect.gen(function* () {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
const doubled = Subscribable.map(count, (n) => n * 2)
return { count, doubled } as const
}),
}) {}
const CounterReadOnlyView = Component.make("CounterReadOnly")(
function* () {
const state = yield* CounterState
const [count, doubled] = yield* Subscribable.useAll([
state.count,
state.doubled,
])
return <p>Count: {count}, doubled: {doubled}</p>
},
)
```
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
```tsx
const CounterControlsView = Component.make("CounterControls")(
function* () {
const state = yield* CounterState
const [count] = yield* Subscribable.useAll([state.count])
const increment = yield* Component.useCallbackSync(
() => Lens.update(state.count, (n) => n + 1),
[],
)
const reset = yield* Component.useCallbackSync(
() => Lens.set(state.count, 0),
[],
)
return (
<section>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={reset}>Reset</button>
</section>
)
},
)
```
## Lens.useState
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
setter, especially controlled inputs such as text fields, checkboxes, selects,
or third-party components with `value` / `onChange` props.
If a component only needs to display the value, prefer `Subscribable.useAll`.
`Lens.useState` is for places where reading and writing need to be wired
together in React's local-state shape.
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens } from "effect-fc"
class FormState extends Effect.Service<FormState>()("FormState", {
effect: Effect.gen(function* () {
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
return { name } as const
}),
}) {}
const NameInputView = Component.make("NameInput")(function* () {
const state = yield* FormState
const [name, setName] = yield* Lens.useState(state.name)
return (
<input
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
)
})
```
`Lens.useState` returns the current value and a React-compatible setter. Calling
the setter writes through the Lens, so every other component subscribed to the
same Lens sees the update.
## Focused Lenses
Use focused Lenses when a component should work with one part of a larger state
object. A focused Lens is still a Lens, so it can be read with
`Subscribable.useAll` or used with `Lens.useState` when React needs a
read/write tuple.
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, Subscribable } from "effect-fc"
interface UserProfile {
readonly name: string
readonly email: string
readonly role: string
}
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
effect: Effect.gen(function* () {
const profile = Lens.fromSubscriptionRef(
yield* SubscriptionRef.make<UserProfile>({
name: "",
email: "",
role: "reader",
}),
)
const name = Lens.focusObjectOn(profile, "name")
const role = Lens.focusObjectOn(profile, "role")
return { profile, name, role } as const
}),
}) {}
const ProfileNameView = Component.make("ProfileName")(function* () {
const state = yield* ProfileState
const [name, setName] = yield* Lens.useState(state.name)
const [role] = yield* Subscribable.useAll([state.role])
return (
<label>
Name
<input
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
<span>Role: {role}</span>
</label>
)
})
```
Updating the focused `name` Lens through `Lens.useState` updates the parent
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
`Subscribable.useAll` path.
For focusing into nested state, deriving lenses, custom write behavior, and the
complete API, refer to the
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
@@ -0,0 +1,7 @@
{
"docsSidebar": [
"getting-started",
"state-management",
"forms"
]
}
+3
View File
@@ -0,0 +1,3 @@
[
"effect-fc-v0"
]
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tanstack
+51
View File
@@ -0,0 +1,51 @@
# Effect FC Example
Example application for the legacy `effect-fc` package, built with React,
TypeScript, and Vite.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**", "!src/routeTree.gen.ts"]
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Effect FC Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@effect-fc/example",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint",
"preview": "vite preview",
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"globals": "^17.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"type-fest": "^5.7.0",
"vite": "^8.0.16"
},
"dependencies": {
"@effect/platform": "^0.96.1",
"@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2",
"effect": "^3.21.2",
"effect-fc": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "^3.21.2",
"react": "^19.2.6"
}
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,56 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
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* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={isCommitting}
{...Struct.omit(props, "form")}
>
{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: () => <></>,
})}
</Flex>
)
}).pipe(
Component.withSignature<TextFieldFormInputView.Signature>()
)
@@ -0,0 +1,64 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
export declare namespace TextFieldOptionalFormInputView {
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting}
{...Struct.omit(props, "form", "defaultValue")}
>
<TextField.Slot side="left">
<Switch
size="1"
checked={input.enabled}
onCheckedChange={input.setEnabled}
/>
</TextField.Slot>
{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: () => <></>,
})}
</Flex>
)
}).pipe(
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
)
+24
View File
@@ -0,0 +1,24 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactRuntime } from "effect-fc"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { routeTree } from "./routeTree.gen"
import { runtime } from "./runtime"
const router = createRouter({ routeTree })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} />
</ReactRuntime.Provider>
</StrictMode>
)
@@ -0,0 +1,210 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevContextRouteImport } from './routes/dev/context'
const ResultRoute = ResultRouteImport.update({
id: '/result',
path: '/result',
getParentRoute: () => rootRouteImport,
} as any)
const QueryRoute = QueryRouteImport.update({
id: '/query',
path: '/query',
getParentRoute: () => rootRouteImport,
} as any)
const FormRoute = FormRouteImport.update({
id: '/form',
path: '/form',
getParentRoute: () => rootRouteImport,
} as any)
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
getParentRoute: () => rootRouteImport,
} as any)
const AsyncRoute = AsyncRouteImport.update({
id: '/async',
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DevMemoRoute = DevMemoRouteImport.update({
id: '/dev/memo',
path: '/dev/memo',
getParentRoute: () => rootRouteImport,
} as any)
const DevContextRoute = DevContextRouteImport.update({
id: '/dev/context',
path: '/dev/context',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute
DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/result': {
id: '/result'
path: '/result'
fullPath: '/result'
preLoaderRoute: typeof ResultRouteImport
parentRoute: typeof rootRouteImport
}
'/query': {
id: '/query'
path: '/query'
fullPath: '/query'
preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport
}
'/form': {
id: '/form'
path: '/form'
fullPath: '/form'
preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport
}
'/blank': {
id: '/blank'
path: '/blank'
fullPath: '/blank'
preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport
}
'/async': {
id: '/async'
path: '/async'
fullPath: '/async'
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/memo': {
id: '/dev/memo'
path: '/dev/memo'
fullPath: '/dev/memo'
preLoaderRoute: typeof DevMemoRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/context': {
id: '/dev/context'
path: '/dev/context'
fullPath: '/dev/context'
preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute,
FormRoute: FormRoute,
QueryRoute: QueryRoute,
ResultRoute: ResultRoute,
DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,28 @@
import { Container, Flex, Theme } from "@radix-ui/themes"
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import "@radix-ui/themes/styles.css"
import "../index.css"
export const Route = createRootRoute({
component: Root
})
function Root() {
return (
<Theme>
<Container mb="4">
<Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link>
<Link to="/blank">Blank</Link>
</Flex>
</Container>
<Outlet />
<TanStackRouterDevtools />
</Theme>
)
}
@@ -0,0 +1,71 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Effect, flow, Option, Schema } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface AsyncFetchPostViewProps {
readonly id: number
}
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
), [props.id])
return (
<div>
<Heading>{post.title}</Heading>
<Text>{post.body}</Text>
</div>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
Memoized.memoized,
) {}
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
const [id, setId] = React.useState(1)
const AsyncFetchPost = yield* AsyncFetchPostView.use
return (
<Container>
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={text}
onChange={e => setText(e.currentTarget.value)}
/>
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/async")({
component: AsyncRouteComponent,
})
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/blank")({
component: RouteComponent
})
function RouteComponent() {
return <div>Hello "/blank"!</div>
}
@@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() {
const ContextView = Component.makeUntraced("ContextView")(function*() {
const [serviceValue, setServiceValue] = React.useState("test")
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
const SubComponentFC = yield* Effect.provide(SubComponent, yield* Component.useContext(SubServiceLayer))
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useLayer(SubServiceLayer))
return (
<Container>
@@ -17,8 +17,8 @@ const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
onChange={e => setValue(e.target.value)}
/>
{yield* Effect.map(SubComponent, FC => <FC />)}
{yield* Effect.map(MemoizedSubComponent, FC => <FC />)}
{yield* Effect.map(SubComponent.use, FC => <FC />)}
{yield* Effect.map(MemoizedSubComponent.use, FC => <FC />)}
</Flex>
)
}).pipe(
@@ -0,0 +1,143 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime"
const email = Schema.pattern<typeof Schema.String>(
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
{
identifier: "email",
title: "email",
message: () => "Not an email address",
},
)
const RegisterFormSchema = Schema.Struct({
email: Schema.String.pipe(email),
password: Schema.String.pipe(Schema.minLength(3)),
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
})
const RegisterFormSubmitSchema = Schema.Struct({
email: Schema.transformOrFail(
Schema.String,
Schema.String,
{
decode: (input, _options, ast) => input !== "admin@admin.com"
? ParseResult.succeed(input)
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
encode: ParseResult.succeed,
},
),
password: Schema.String,
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
})
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: ParseResult.succeed,
},
),
),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() },
f: Effect.fnUntraced(function*([value]) {
yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}),
})
return {
form,
emailField: Form.focusObjectOn(form, "email"),
passwordField: Form.focusObjectOn(form, "password"),
birthField: Form.focusObjectOn(form, "birth"),
} as const
})
}) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterFormService
const [canCommit, submitResult] = yield* Subscribable.useAll([
form.form.canCommit,
form.form.mutation.result,
])
const runPromise = yield* Component.useRunPromise()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
yield* Console.log("RegisterFormView mounted")
}))
return (
<Container width="300">
<form onSubmit={e => {
e.preventDefault()
void runPromise(form.form.submit)
}}>
<Flex direction="column" gap="2">
<TextFieldFormInput
form={form.emailField}
debounce="250 millis"
/>
<TextFieldFormInput
form={form.passwordField}
debounce="250 millis"
/>
<TextFieldOptionalFormInput
type="datetime-local"
form={form.birthField}
defaultValue=""
/>
<Button disabled={!canCommit}>Submit</Button>
</Flex>
</form>
{Match.value(submitResult).pipe(
Match.tag("Initial", () => <></>),
Match.tag("Running", () => <Text>Submitting...</Text>),
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
Match.exhaustive,
)}
</Container>
)
}) {}
const RegisterPage = Component.make("RegisterPageView")(function*() {
const RegisterForm = yield* Effect.provide(
RegisterFormView.use,
yield* Component.useLayer(RegisterFormService.Default),
)
return <RegisterForm />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/form")({
component: RegisterPage
})
@@ -0,0 +1,24 @@
import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { runtime } from "@/runtime"
import { TodosState } from "@/todo/TodosState"
import { TodosView } from "@/todo/TodosView"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.make("IndexView")(function*() {
const Todos = yield* Effect.provide(
TodosView.use,
yield* Component.useLayer(TodosStateLive),
)
return <Todos />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Index
})
@@ -0,0 +1,117 @@
import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise()
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const key = Stream.map(idLens.changes, id => [id] as const)
const query = yield* Query.service({
key,
f: ([id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
})
return [idLens, query, mutation] as const
}))
const [id, setId] = yield* Lens.useState(idLens)
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),
Effect.andThen(Stream.fromQueue),
Stream.unwrapScoped,
Stream.runForEach(flow(
Cause.failures,
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
Option.match({
onSome: e => Console.log("ResultView HttpClient error", e),
onNone: () => Effect.void,
}),
)),
Effect.forkScoped,
))
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<div>
{Match.value(queryResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
</Flex>
<div>
{Match.value(mutationResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
</Flex>
</Flex>
</Container>
)
})
export const Route = createFileRoute("/query")({
component: Component.withRuntime(ResultView, runtime.context)
})
@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Effect.tap(Effect.sleep("250 millis")),
Result.forkEffect,
))
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
const [result] = yield* Subscribable.useAll([resultSubscribable])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),
+15
View File
@@ -0,0 +1,15 @@
import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { DateTime, Layer } from "effect"
import { ReactRuntime } from "effect-fc"
export const AppLive = Layer.empty.pipe(
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer),
Layer.provideMerge(Permissions.layer),
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
@@ -0,0 +1,88 @@
import { Box, Flex, IconButton } from "@radix-ui/themes"
import { Effect } from "effect"
import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
export interface EditTodoViewProps {
readonly id: string
}
export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) {
const state = yield* TodosState
const [
indexSubscribable,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexSubscribable = state.getIndexSubscribable(props.id)
const form = yield* SynchronizedForm.service({
schema: TodoFormSchema,
target: state.getElementLens(props.id),
})
return [
indexSubscribable,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}), [props.id])
const [index, size] = yield* Subscribable.useAll([
indexSubscribable,
state.sizeSubscribable,
])
const runSync = yield* Component.useRunSync()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
form={completedAtField}
type="datetime-local"
defaultValue=""
/>
</Flex>
</Flex>
</Box>
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => runSync(state.moveLeft(props.id))}
>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => runSync(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => runSync(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
</Flex>
)
}) {}
@@ -0,0 +1,78 @@
import { Box, Button, Flex } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Option, Schema } from "effect"
import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc"
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export class NewTodoView extends Component.make("NewTodoView")(function*() {
const state = yield* TodosState
const [
form,
contentField,
completedAtField,
] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo),
f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Lens.set(form.encodedValue, v)),
),
})
return [
form,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}))
const [canCommit] = yield* Subscribable.useAll([form.canCommit])
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
form={completedAtField}
type="datetime-local"
defaultValue=""
/>
<Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
Add
</Button>
</Flex>
</Flex>
</Box>
</Flex>
)
}) {}
@@ -0,0 +1,9 @@
import { Schema } from "effect"
import * as Domain from "@/domain"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
export const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)
@@ -1,7 +1,7 @@
import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Subscribable, SubscriptionSubRef } from "effect-fc"
import { Lens, Subscribable } from "effect-fc"
import { Todo } from "@/domain"
@@ -30,27 +30,29 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: kv.remove(key)
)
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
yield* Effect.forkScoped(ref.changes.pipe(
const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage))
yield* Effect.forkScoped(lens.changes.pipe(
Stream.debounce("500 millis"),
Stream.runForEach(saveToLocalStorage),
))
yield* Effect.addFinalizer(() => ref.pipe(
yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
Effect.andThen(saveToLocalStorage),
Effect.ignore,
))
const sizeSubscribable = Subscribable.make({
get: Effect.andThen(ref, Chunk.size),
get changes() { return Stream.map(ref.changes, Chunk.size) },
})
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
const getIndexSubscribable = (id: string) => Subscribable.make({
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
})
const sizeSubscribable = Subscribable.map(lens, Chunk.size)
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
const getElementLens = (id: string) => Lens.mapEffect(
lens,
Chunk.findFirst(v => v.id === id),
(a, b) => Effect.flatMap(
Chunk.findFirstIndex(a, v => v.id === id),
i => Chunk.replaceOption(a, i, b),
)
)
const getIndexSubscribable = (id: string) => Subscribable.mapEffect(lens, Chunk.findFirstIndex(v => v.id === id))
const moveLeft = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
@@ -62,7 +64,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos
),
))
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
@@ -74,15 +76,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos
),
))
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen(
Chunk.findFirstIndex(todos, v => v.id === id),
index => Chunk.remove(todos, index),
))
return {
ref,
lens,
sizeSubscribable,
getElementRef,
getElementLens,
getIndexSubscribable,
moveLeft,
moveRight,
@@ -1,30 +1,32 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component, Subscribable } from "effect-fc"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
import { EditTodoView } from "./EditTodoView"
import { NewTodoView } from "./NewTodoView"
import { TodosState } from "./TodosState"
export class Todos extends Component.makeUntraced("Todos")(function*() {
export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState
const [todos] = yield* Subscribable.useSubscribables([state.ref])
const [todos] = yield* Subscribable.useAll([state.lens])
yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const TodoFC = yield* Todo
const NewTodo = yield* NewTodoView.use
const EditTodo = yield* EditTodoView.use
return (
<Container>
<Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" />
<NewTodo />
{Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
<EditTodo key={todo.id} id={todo.id} />
)}
</Flex>
</Container>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{ "name": "@effect/language-service" }
]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import path from "node:path"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: "react",
autoCodeSplitting: true,
}),
react(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})
+14 -22
View File
@@ -1,59 +1,51 @@
# Effect FC
[Effect-TS](https://effect.website/) integration for React 19+ that allows you to write function components using Effect generators.
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
Documentation is currently being written. In the meantime, you can take a look at the `packages/effect-fc-example` directory.
## Peer dependencies
- `effect` 3.15+
- `react` & `@types/react` 19+
- `effect` 3.19+
- `react` & `@types/react` 19.2+
## Known issues
- React Refresh doesn't work for Effect FC's yet. Page reload is required to view changes. Regular React components are unaffected.
## What writing components looks like
```typescript
import { Component } from "effect-fc"
import { useOnce, useSubscribables } from "effect-fc/Hooks"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
export class Todos extends Component.makeUntraced("Todos")(function*() {
export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState
const [todos] = yield* useSubscribables(state.ref)
const [todos] = yield* Component.useSubscribables([state.subscriptionRef])
yield* useOnce(() => Effect.andThen(
yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const TodoFC = yield* Todo
const Todo = yield* TodoView.use
return (
<Container>
<Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" />
<Todo _tag="new" />
{Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
<Todo key={todo.id} _tag="edit" id={todo.id} />
)}
</Flex>
</Container>
)
}) {}
const TodosStateLive = TodosState.Default("todos")
const Index = Component.make("IndexView")(function*() {
const context = yield* Component.useLayer(TodosState.Default)
const Todos = yield* Effect.provide(TodosView.use, context)
const Index = Component.makeUntraced("Index")(function*() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)
return <TodosFC />
return <Todos />
}).pipe(
Component.withRuntime(runtime.context)
)
+16 -6
View File
@@ -1,7 +1,7 @@
{
"name": "effect-fc",
"description": "Write React function components with Effect",
"version": "0.2.1",
"version": "0.3.1",
"type": "module",
"files": [
"./README.md",
@@ -9,7 +9,7 @@
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-fc.git"
"url": "git+https://github.com/Thiladev/effect-view.git"
},
"types": "./dist/index.d.ts",
"exports": {
@@ -29,17 +29,27 @@
]
},
"scripts": {
"build": "tsc",
"lint:tsc": "tsc --noEmit",
"lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint",
"test": "vitest run",
"build": "tsc -b tsconfig.build.json",
"pack": "npm pack",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "^0.76.0",
"@testing-library/react": "^16.3.0",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "^3.19.0",
"effect": "^3.21.0",
"react": "^19.2.0"
},
"dependencies": {
"effect-lens": "^0.2.3"
}
}
+117 -31
View File
@@ -1,35 +1,49 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type TypeId = typeof TypeId
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type AsyncTypeId = typeof AsyncTypeId
export interface Async extends Async.Options {
readonly [TypeId]: TypeId
/**
* A trait for `Component`'s that allows them running asynchronous effects.
*/
export interface Async extends AsyncPrototype, AsyncOptions {}
export interface AsyncPrototype {
readonly [AsyncTypeId]: AsyncTypeId
}
export namespace Async {
export interface Options {
readonly defaultFallback?: React.ReactNode
}
export type Props = Omit<React.SuspenseProps, "children">
/**
* Configuration options for `Async` components.
*/
export interface AsyncOptions {
/**
* The default fallback React node to display while the async operation is pending.
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode
}
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children">
const AsyncProto = Object.freeze({
[TypeId]: TypeId,
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component.Component<P, A, E, R> & Async,
export const AsyncPrototype: AsyncPrototype = Object.freeze({
[AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R, F extends Component.Component.Signature>(
this: Component.Component<P, A, E, R, F> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) {
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: Async.Props) => {
return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Runtime.runPromise(runtimeRef.current)(
Effect.andThen(
Component.useScope([], this),
@@ -40,45 +54,117 @@ const AsyncProto = Object.freeze({
return React.createElement(
React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name },
React.createElement(SuspenseInner, { promise }),
React.createElement(Inner, { promise }),
)
}
},
} as const)
/**
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
* Used by default by async components with `Memoized.memoized` applied.
*/
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
self: Record<string, unknown>,
that: Record<string, unknown>,
) => {
if (self === that)
return true
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
for (const key in self) {
if (key === "fallback")
continue
if (!(key in that) || !Object.is(self[key], that[key]))
return false
}
export const async = <T extends Component.Component<any, any, any, any>>(
self: T
for (const key in that) {
if (key === "fallback")
continue
if (!(key in self))
return false
}
return true
}
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
/**
* Converts a Component into an `Async` component that supports running asynchronous effects.
*
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
*
* @param self - The component to convert to an Async component
* @returns A new `Async` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* )
* ```
*/
export const async = <T extends Component.Component.Any>(
self: T & (
"promise" extends keyof Component.Component.Props<T>
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
: T
)
): (
& Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component<
Component.Component.Props<T> & Async.Props,
Component.Component.Props<T> & AsyncProps,
Component.Component.Success<T>,
Component.Component.Error<T>,
Component.Component.Context<T>
Component.Component.Context<T>,
Component.Component.DefaultSignature<Component.Component.Props<T> & AsyncProps, Component.Component.Success<T>>
>
& Async
) => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncProto),
Object.assign({}, AsyncPrototype),
Object.getPrototypeOf(self),
)),
)
/**
* Applies options to an Async component, returning a new Async component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Async component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns An Async component with the applied options
*
* @example
* ```ts
* // Curried
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
* )
*
* // Uncurried
* const MyAsyncComponent = Async.withOptions(
* Async.async(MyComponent),
* { defaultFallback: <p>Loading...</p> },
* )
* ```
*/
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Async>(
options: Partial<Async.Options>
<T extends Component.Component.Any & Async>(
options: Partial<AsyncOptions>
): (self: T) => T
<T extends Component.Component<any, any, any, any> & Async>(
<T extends Component.Component.Any & Async>(
self: T,
options: Partial<Async.Options>,
options: Partial<AsyncOptions>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
} = Function.dual(2, <T extends Component.Component.Any & Async>(
self: T,
options: Partial<Async.Options>,
options: Partial<AsyncOptions>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
+358
View File
@@ -0,0 +1,358 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Context, Effect, Layer } from "effect"
import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "./Component.js"
import * as ReactRuntime from "./ReactRuntime.js"
class ValueService extends Context.Tag("ValueService")<ValueService, { readonly value: string }>() {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
it("runs useOnMount only once across rerenders", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
const value = yield* Component.useOnMount(onMount)
return <div>{value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("mounted")
expect(onMount).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("mounted")).toBeTruthy()
expect(onMount).toHaveBeenCalledTimes(1)
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("recomputes useOnChange only when dependencies change", async () => {
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
finalizerExecutionDebounce: 0,
})
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
await screen.findByText("value:1")
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
expect(await screen.findByText("value:1")).toBeTruthy()
expect(onChange).toHaveBeenCalledTimes(1)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={2} />
</runtime.context.Provider>
)
await screen.findByText("value:2")
expect(onChange).toHaveBeenCalledTimes(2)
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("closes the previous scope on dependency changes and unmount", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("ScopeCleanupProbe")(function*(props: { readonly value: string }) {
const result = yield* Component.useOnChange(
() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
return props.value
}),
[props.value],
{ finalizerExecutionDebounce: 0 },
)
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(cleanup).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
expect(cleanup).toHaveBeenCalledTimes(1)
view.unmount()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
expect(cleanup).toHaveBeenCalledTimes(2)
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("runs useReactEffect setup and cleanup when dependencies change", async () => {
const lifecycle = vi.fn<(message: string) => void>()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) {
yield* Component.useReactEffect(() =>
Effect.gen(function*() {
yield* Effect.sync(() => lifecycle(`mount:${props.value}`))
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`)))
}),
[props.value])
return <div>{props.value}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first"))
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second"))
view.unmount()
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
"mount:first",
"cleanup:first",
"mount:second",
"cleanup:second",
])
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("keeps useCallbackSync stable until dependencies change", async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const seenCallbacks: Array<(value: number) => string> = []
const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) {
const callback = yield* Component.useCallbackSync(
(value: number) => Effect.succeed(`${props.prefix}:${value}`),
[props.prefix],
)
yield* Component.useOnMount(() => Effect.sync(() => {
seenCallbacks.push(callback)
}))
React.useEffect(() => {
seenCallbacks.push(callback)
}, [callback])
return <div>{callback(1)}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
expect(seenCallbacks[0]?.(2)).toBe("a:2")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="b" />
</runtime.context.Provider>
)
await screen.findByText("b:1")
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
expect(seenCallbacks[2]?.(2)).toBe("b:2")
view.unmount()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("delays cleanup according to finalizerExecutionDebounce", async () => {
const cleanup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) {
const result = yield* Component.useOnChange(
() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value)))
return props.value
}),
[props.value],
{ finalizerExecutionDebounce: "20 millis" },
)
return <div>{result}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
vi.useFakeTimers()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value="second" />
</runtime.context.Provider>
)
expect(screen.getByText("second")).toBeTruthy()
expect(cleanup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(19)
expect(cleanup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(cleanup).toHaveBeenCalledWith("first")
view.unmount()
await vi.advanceTimersByTimeAsync(20)
expect(cleanup).toHaveBeenCalledWith("second")
vi.useRealTimers()
await Effect.runPromise(runtime.runtime.disposeEffect)
})
it("does not remount a component when only nonReactiveTags change", async () => {
const mounts = vi.fn()
const unmounts = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
const service = yield* ValueService
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.sync(() => mounts())
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
}))
return <div>{service.value}</div>
}).pipe(
Component.withOptions({ nonReactiveTags: [ValueService] })
)
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
const serviceLayer = React.useMemo(
() => Layer.succeed(ValueService, { value: props.value }),
[props.value],
)
const context = yield* Component.useLayer(serviceLayer, {
finalizerExecutionDebounce: 0,
})
const Child = yield* Effect.provide(SubComponent.use, context)
return <Child />
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Parent value="first" />
</runtime.context.Provider>
)
await screen.findByText("first")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Parent value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(mounts).toHaveBeenCalledTimes(1)
expect(unmounts).not.toHaveBeenCalled()
view.unmount()
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
await Effect.runPromise(runtime.runtime.disposeEffect)
})
})
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,20 +1,20 @@
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type TypeId = typeof TypeId
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
readonly [TypeId]: TypeId
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
}
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
class ErrorObserverImpl<in out E = never>
export class ErrorObserverImpl<in out E = never>
extends Pipeable.Class() implements ErrorObserver<E> {
readonly [TypeId]: TypeId = TypeId
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
constructor(
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
}
}
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
readonly value = Effect.void
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
super()
@@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
}
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId)
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
PubSub.unbounded<Cause.Cause<never>>(),
+176 -299
View File
@@ -1,294 +1,157 @@
import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect"
import { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, type ParseResult, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import type * as React from "react"
import * as Component from "./Component.js"
import * as Mutation from "./Mutation.js"
import * as PropertyPath from "./PropertyPath.js"
import * as Result from "./Result.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
import * as SubscriptionRef from "./SubscriptionRef.js"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
export type FormTypeId = typeof FormTypeId
export interface Form<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly autosubmit: boolean
readonly debounce: Option.Option<Duration.DurationInput>
readonly value: Subscribable.Subscribable<Option.Option<A>>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
readonly canSubmit: Subscribable.Subscribable<boolean>
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
readonly path: P
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
}
export class FormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
readonly [FormTypeId]: FormTypeId = FormTypeId
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly autosubmit: boolean,
readonly debounce: Option.Option<Duration.DurationInput>,
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
readonly runSemaphore: Effect.Semaphore,
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
readonly path: P,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
) {
super()
this.canSubmit = Subscribable.map(
Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result),
([value, error, validationFiber, result]) => (
Option.isSome(value) &&
Option.isNone(error) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.isRefreshing(result))
),
)
}
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> {
const key = new FormFieldKey(path)
return this.fieldCache.pipe(
Effect.map(HashMap.get(key)),
Effect.flatMap(Option.match({
onSome: v => Effect.succeed(v as FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>),
onNone: () => Effect.tap(
Effect.succeed(makeFormField(this as Form<A, I, R, MA, ME, MR, MP>, path)),
v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField<unknown, unknown>)),
),
})),
)
}
readonly canSubmit: Subscribable.Subscribable<boolean, never, never>
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
return this.value.pipe(
Effect.andThen(identity),
Effect.andThen(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
return Effect.whenEffect(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => Result.isFailure(result)
? Option.match(
Chunk.findFirst(
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
e => e._tag === "ParseError",
),
{
onSome: e => Ref.set(this.error, Option.some(e)),
onNone: () => Effect.void,
},
)
: Effect.void
),
this.canSubmit.get,
)
}
}
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
readonly autosubmit?: boolean
readonly debounce?: Duration.DurationInput
}
}
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
const filterIssuesByPath = (
issues: readonly ParseResult.ArrayFormatterIssue[],
path: readonly PropertyKey[],
): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue =>
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
)
export const focusObjectOn: {
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
key: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, key] as const
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>
): Effect.fn.Return<
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
> {
return new FormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
options.autosubmit ?? false,
Option.fromNullable(options.debounce),
yield* SubscriptionRef.make(Option.none<A>()),
yield* SubscriptionRef.make(options.initialEncodedValue),
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()),
yield* Effect.makeSemaphore(1),
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
path,
Subscribable.mapOption(form.value, a => a[key]),
Lens.focusObjectOn(form.encodedValue, key),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const run = <A, I, R, MA, ME, MR, MP>(
self: Form<A, I, R, MA, ME, MR, MP>
): Effect.Effect<void> => {
const _self = self as FormImpl<A, I, R, MA, ME, MR, MP>
return _self.runSemaphore.withPermits(1)(Stream.runForEach(
_self.encodedValue.changes.pipe(
Option.isSome(_self.debounce) ? Stream.debounce(_self.debounce.value) : identity
),
export const focusArrayAt: {
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
index: number,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
encodedValue => _self.validationFiber.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.forkScoped(Effect.onExit(
Schema.decode(_self.schema, { errors: "all" })(encodedValue),
exit => Effect.andThen(
Exit.matchEffect(exit, {
onSuccess: v => Effect.andThen(
Ref.set(_self.value, Option.some(v)),
Ref.set(_self.error, Option.none()),
),
onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), {
onSome: e => Ref.set(_self.error, Option.some(e)),
onNone: () => Effect.void,
}),
}),
Ref.set(_self.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Ref.set(_self.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.andThen(value => _self.autosubmit
? Effect.asVoid(Effect.forkScoped(_self.submitValue(value)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.provide(_self.context),
),
))
}
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>
): Effect.Effect<
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
> => Effect.tap(
make(options),
form => Effect.forkScoped(run(form)),
)
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidating: Subscribable.Subscribable<boolean>
readonly isSubmitting: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidating: Subscribable.Subscribable<boolean>,
readonly isSubmitting: Subscribable.Subscribable<boolean>,
) {
super()
}
}
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
class FormFieldKey implements Equal.Equal {
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
constructor(readonly path: PropertyPath.PropertyPath) {}
[Equal.symbol](that: Equal.Equal) {
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
}
[Hash.symbol]() {
return Hash.array(this.path)
}
}
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, MA, ME, MR, MP>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
const _self = self as FormImpl<A, I, R, MA, ME, MR, MP>
return new FormFieldImpl(
Subscribable.mapEffect(_self.value, Option.match({
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
})),
SubscriptionSubRef.makeFromPath(_self.encodedValue, path),
Subscribable.mapEffect(_self.error, Option.match({
onSome: flow(
ParseResult.ArrayFormatter.formatError,
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
),
onNone: () => Effect.succeed([]),
})),
Subscribable.map(_self.validationFiber, Option.isSome),
Subscribable.map(_self.mutation.result, result => Result.isRunning(result) || Result.isRefreshing(result)),
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, Array.get(index)),
Lens.focusArrayAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
}
})
export const focusTupleAt: {
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
index: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOption(form.value, Array.unsafeGet(index)),
Lens.focusTupleAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusChunkAt: {
<P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
<P extends readonly PropertyKey[], A, I, ER, EW>(
index: number,
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW> => {
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, Chunk.get(index)),
Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export namespace useInput {
@@ -302,33 +165,39 @@ export namespace useInput {
}
}
export const useInput = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
form: Form<P, A, I, ER, EW>,
options?: useInput.Options,
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(field.encodedValue, SubscriptionRef.make),
internalValueRef => Effect.forkScoped(Effect.all([
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
Effect.flatMap(SubscriptionRef.make),
Effect.map(Lens.fromSubscriptionRef),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValue, 1),
Stream.drop(form.encodedValue.changes, 1),
upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
Lens.set(internalValueLens, upstreamEncodedValue),
Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
),
),
Stream.runForEach(
internalValueRef.changes.pipe(
internalValueLens.changes.pipe(
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
internalValue => Ref.set(field.encodedValue, internalValue),
internalValue => Lens.set(form.encodedValue, internalValue),
),
], { concurrency: "unbounded" })),
), [field, options?.debounce])
], { concurrency: "unbounded", discard: true }))
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
return internalValueLens
}), [form, options?.debounce])
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { value, setValue }
})
@@ -343,55 +212,63 @@ export namespace useOptionalInput {
}
}
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: Form<P, A, Option.Option<I>, ER, EW>,
options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(
field.encodedValue,
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() {
const [enabledLens, internalValueLens] = yield* Effect.flatMap(
Lens.get(field.encodedValue),
Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
onSome: v => Effect.all([
Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef),
Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef),
]),
onNone: () => Effect.all([
Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef),
Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef),
]),
}),
),
)
([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([
yield* Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValue, 1),
Stream.drop(field.encodedValue.changes, 1),
upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen(
Ref.set(enabledRef, true),
Ref.set(internalValueRef, v),
Lens.set(enabledLens, true),
Lens.set(internalValueLens, v),
),
onNone: () => Effect.andThen(
Ref.set(enabledRef, false),
Ref.set(internalValueRef, options.defaultValue),
Lens.set(enabledLens, false),
Lens.set(internalValueLens, options.defaultValue),
),
}),
Effect.andThen(
Effect.all([enabledRef, internalValueRef]),
Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
),
),
Stream.runForEach(
enabledRef.changes.pipe(
Stream.zipLatest(internalValueRef.changes),
enabledLens.changes.pipe(
Stream.zipLatest(internalValueLens.changes),
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
], { concurrency: "unbounded" })),
), [field, options.debounce])
], { concurrency: "unbounded" }))
const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef)
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
return [enabledLens, internalValueLens] as const
}), [field, options.debounce])
const [enabled, setEnabled] = yield* Lens.useState(enabledLens)
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { enabled, setEnabled, value, setValue }
})
+171
View File
@@ -0,0 +1,171 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { Effect, Layer, SubscriptionRef } from "effect"
import * as React from "react"
import { describe, expect, it } from "vitest"
import * as Component from "./Component.js"
import * as Lens from "./Lens.js"
import * as ReactRuntime from "./ReactRuntime.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
return {
runtime,
effectRuntime,
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
}
}
const expectDefined = <A,>(value: A | undefined): A => {
if (value === undefined)
throw new Error("Expected value to be defined")
return value
}
describe("Lens", () => {
it("useState stays in sync with lens updates in both directions", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const ref = await Effect.runPromise(SubscriptionRef.make(0))
const lens = Lens.fromSubscriptionRef(ref)
const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
const [value, setValue] = yield* Lens.useState(lens)
return (
<>
<div>{value}</div>
<button type="button" onClick={() => setValue(previous => previous + 1)}>increment</button>
</>
)
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("0")
await Effect.runPromise(Lens.set(lens, 5))
await screen.findByText("5")
fireEvent.click(screen.getByRole("button", { name: "increment" }))
await screen.findByText("6")
expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
view.unmount()
await dispose()
})
it("useState respects the provided equivalence when subscribing to lens changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
const lens = Lens.fromSubscriptionRef(ref)
const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
const [value] = yield* Lens.useState(lens, {
equivalence: (self, that) => self.id === that.id,
})
return <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first")
await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
expect(screen.queryByText("ignored")).toBeNull()
await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
await screen.findByText("updated")
view.unmount()
await dispose()
})
it("useFromReactState writes React state changes into the returned lens", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
let lens: Lens.Lens<string, never, never, never, never> | undefined
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
const [value, setValue] = React.useState("hello")
const reactLens = yield* Lens.useFromReactState([value, setValue])
yield* Component.useOnMount(() => Effect.sync(() => {
lens = reactLens
}))
return <button type="button" onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("hello")
await waitFor(() => expect(lens).toBeDefined())
fireEvent.click(screen.getByRole("button", { name: "hello" }))
await screen.findByText("hello!")
await waitFor(async () => expect(await Effect.runPromise(Lens.get(expectDefined(lens)))).toBe("hello!"))
view.unmount()
await dispose()
})
it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
const [value, setValue] = React.useState({ id: 1, label: "first" })
const reactLens = yield* Lens.useFromReactState([value, setValue], {
equivalence: (self, that) => self.id === that.id,
})
yield* Component.useOnMount(() => Effect.sync(() => {
lens = reactLens
}))
return <div>{value.label}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first")
await waitFor(() => expect(lens).toBeDefined())
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
expect(screen.queryByText("ignored")).toBeNull()
await Effect.runPromise(Lens.set(expectDefined(lens), { id: 2, label: "updated" }))
await screen.findByText("updated")
view.unmount()
await dispose()
})
})
+62
View File
@@ -0,0 +1,62 @@
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
export * from "effect-lens/Lens"
export declare namespace useState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
lens: Lens.Lens<A, ER, EW, RR, RW>,
options?: useState.Options<NoInfer<A>>,
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>], ER, RR | RW> {
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens)))
yield* Component.useReactEffect(() => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
)
), [lens])
const setValue = yield* Component.useCallbackSync(
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[lens],
)
return [reactStateValue, setValue]
})
export declare namespace useFromReactState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useFromReactState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useFromReactState.Options<NoInfer<A>>,
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
const lens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(value),
Lens.fromSubscriptionRef,
))
yield* Component.useReactEffect(() => Effect.forkScoped(Stream.runForEach(
Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setValue(v)),
)), [setValue])
yield* Component.useReactEffect(() => Lens.set(lens, value), [value])
return lens
})
+80 -19
View File
@@ -1,50 +1,111 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { type Equivalence, Function, Predicate } from "effect"
import * as React from "react"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type TypeId = typeof TypeId
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type MemoizedTypeId = typeof MemoizedTypeId
export interface Memoized<P> extends Memoized.Options<P> {
readonly [TypeId]: TypeId
/**
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
*
* @template P The props type of the component
*/
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
export interface MemoizedPrototype {
readonly [MemoizedTypeId]: MemoizedTypeId
}
export namespace Memoized {
export interface Options<P> {
readonly propsAreEqual?: Equivalence.Equivalence<P>
}
/**
* Configuration options for Memoized components.
*
* @template P The props type of the component
*/
export interface MemoizedOptions<P> {
/**
* An optional equivalence function for comparing component props.
* If provided, this function is used by React.memo to determine if props have changed.
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
*/
readonly propsEquivalence?: Equivalence.Equivalence<P>
}
const MemoizedProto = Object.freeze({
[TypeId]: TypeId
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
[MemoizedTypeId]: MemoizedTypeId,
transformFunctionComponent<P extends {}>(
this: Memoized<P>,
f: React.FC<P>,
) {
return React.memo(f, this.propsEquivalence)
},
} as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
export const memoized = <T extends Component.Component<any, any, any, any>>(
/**
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
*
* @param self - The component to convert to a Memoized component
* @returns A new `Memoized` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* )
* ```
*/
export const memoized = <T extends Component.Component.Any>(
self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, MemoizedProto),
Object.assign({}, MemoizedPrototype),
Object.getPrototypeOf(self),
)),
)
/**
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Memoized component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns A Memoized component with the applied options
*
* @example
* ```ts
* // Curried
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
* )
*
* // Uncurried
* const MyMemoizedComponent = Memoized.withOptions(
* Memoized.memoized(MyComponent),
* { propsEquivalence: (a, b) => a.id === b.id },
* )
* ```
*/
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
options: Partial<Memoized.Options<Component.Component.Props<T>>>
<T extends Component.Component.Any & Memoized<any>>(
options: Partial<MemoizedOptions<Component.Component.Props<T>>>
): (self: T) => T
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
<T extends Component.Component.Any & Memoized<any>>(
self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
} = Function.dual(2, <T extends Component.Component.Any & Memoized<any>>(
self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
options: Partial<MemoizedOptions<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
+25 -22
View File
@@ -16,6 +16,7 @@ extends Pipeable.Pipeable {
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
mutate(key: K): Effect.Effect<Result.Final<A, E, P>>
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
@@ -30,27 +31,30 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
readonly [MutationTypeId]: MutationTypeId = MutationTypeId
constructor(
readonly context: Context.Context<Scope.Scope | NoInfer<R>>,
readonly context: Context.Context<Scope.Scope | R>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
) {
super()
}
mutate(key: K): Effect.Effect<Result.Final<A, E, P>> {
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(Effect.provide(this.start(key), this.context)),
Effect.andThen(this.start(key)),
Effect.andThen(sub => this.watch(sub)),
Effect.provide(this.context),
)
}
mutateSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return Effect.andThen(
SubscriptionRef.set(this.latestKey, Option.some(key)),
Effect.provide(this.start(key), this.context)
return SubscriptionRef.set(this.latestKey, Option.some(key)).pipe(
Effect.andThen(this.start(key)),
Effect.tap(sub => Effect.forkScoped(this.watch(sub))),
Effect.provide(this.context),
)
}
@@ -59,12 +63,8 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
never,
Scope.Scope | R
> {
return this.result.pipe(
Effect.map(previous => Result.isFinal(previous)
? previous
: undefined
),
Effect.andThen(previous => Result.unsafeForkEffect(
return this.latestFinalResult.pipe(
Effect.andThen(initial => Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
@@ -72,12 +72,12 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
onNone: () => Effect.void,
})
}),
)),
{
initial: Option.isSome(initial) ? Result.willFetch(initial.value) : Result.initial(),
initialProgress: this.initialProgress,
previous,
} as Result.unsafeForkEffect.Options<A, E, P>,
)),
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
@@ -88,19 +88,21 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
watch(
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return Effect.andThen(
sub.get,
initial => Stream.runFoldEffect(
Stream.filter(sub.changes, Predicate.not(Result.isInitial)),
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
sub.changes,
initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
),
) as Effect.Effect<Result.Final<A, E, P>>
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
)
}
}
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace make {
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
@@ -111,17 +113,18 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | Result.forkEffect.OutputContext<R, P>
> {
return new MutationImpl(
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
options.f as any,
options.initialProgress as P,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
)
})
-98
View File
@@ -1,98 +0,0 @@
import { Array, Equivalence, Function, Option, Predicate } from "effect"
export type PropertyPath = readonly PropertyKey[]
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
export type Paths<T, D extends number = 5, Seen = never> = readonly [] | (
D extends never ? readonly [] :
T extends Seen ? readonly [] :
T extends readonly any[] ? {
[K in keyof T as K extends number ? K : never]:
| readonly [K]
| readonly [K, ...Paths<T[K], Prev[D], Seen | T>]
} extends infer O
? O[keyof O]
: never
:
T extends object ? {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? readonly [K] | readonly [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
:
never
)
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
? Head extends keyof T
? ValueFromPath<T[Head], Tail>
: T extends readonly any[]
? Head extends number
? ValueFromPath<T[number], Tail>
: never
: never
: T
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
export const unsafeGet: {
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
path.reduce((acc: any, key: any) => acc?.[key], self)
)
export const get: {
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
path.reduce(
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
? Predicate.hasProperty(acc.value, key)
? Option.some(acc.value[key])
: Option.none()
: acc,
Option.some(self),
)
)
export const immutableSet: {
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => Option.Option<T>
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
const key = Array.head(path as PropertyPath)
if (Option.isNone(key))
return Option.some(value as T)
if (!Predicate.hasProperty(self, key.value))
return Option.none()
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
if (Option.isNone(child))
return child
if (Array.isArray(self))
return typeof key.value === "number"
? Option.some([
...self.slice(0, key.value),
child.value,
...self.slice(key.value + 1),
] as T)
: Option.none()
if (typeof self === "object")
return Option.some(
Object.assign(
Object.create(Object.getPrototypeOf(self)),
{ ...self, [key.value]: child.value },
)
)
return Option.none()
})
+1 -1
View File
@@ -3,7 +3,7 @@ import type * as React from "react"
import * as Component from "./Component.js"
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
values: A
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))
+169
View File
@@ -0,0 +1,169 @@
import { Effect, Option, type Scope, Stream } from "effect"
import { describe, expect, it } from "vitest"
import * as Query from "./Query.js"
import * as QueryClient from "./QueryClient.js"
import * as Result from "./Result.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.QueryClient.Default),
)))
const expectSuccessValue = <A, E, P>(
result: Result.Result<A, E, P>,
): A => {
expect(Result.isSuccess(result)).toBe(true)
if (!Result.isSuccess(result))
throw new Error(`Expected Success result, received ${result._tag}`)
return result.value
}
const expectSomeValue = <A>(option: Option.Option<A>): A => {
expect(Option.isSome(option)).toBe(true)
if (!Option.isSome(option))
throw new Error("Expected Some option, received None")
return option.value
}
describe("Query", () => {
it("fetch caches successful results until they are invalidated or stale", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(1)
expect(result[0]._tag).toBe("Success")
expect(result[1]._tag).toBe("Success")
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:1")
})
it("refresh reruns the latest query key", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "0 millis",
})
const first = yield* query.fetch([1])
yield* Effect.sleep("1 millis")
const refreshed = yield* query.refresh
return [first, refreshed] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCacheEntry([1])
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("invalidateCache clears cached entries for the query function", async () => {
let calls = 0
const key = Stream.empty as Stream.Stream<readonly [number]>
const result = await runQueryTest(Effect.gen(function*() {
const query = yield* Query.make({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
const first = yield* query.fetch([1])
yield* query.invalidateCache
const second = yield* query.fetch([1])
return [first, second] as const
}))
expect(calls).toBe(2)
expect(expectSuccessValue(result[0])).toBe("value:1:1")
expect(expectSuccessValue(result[1])).toBe("value:1:2")
})
it("service starts the key stream automatically and updates latest state", async () => {
let calls = 0
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
const effect = Effect.gen(function*() {
const query = yield* Query.service({
key,
f: ([id]: readonly [number]) => Effect.sync(() => {
calls += 1
return `value:${id}:${calls}`
}),
staleTime: "1 minute",
})
yield* Effect.sleep("10 millis")
return {
final: yield* query.result.get,
latestKey: yield* query.latestKey.get,
latestFinalResult: yield* query.latestFinalResult.get,
}
})
const result = await runQueryTest(effect)
expect(calls).toBe(1)
expect(expectSuccessValue(result.final)).toBe("value:1:1")
expect(expectSomeValue(result.latestKey)).toEqual([1])
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
})
})
+166 -103
View File
@@ -1,4 +1,4 @@
import { type Cause, type Context, DateTime, type Duration, Effect, Exit, Fiber, HashMap, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import { type Cause, type Context, type Duration, Effect, Equal, Fiber, identity, Option, Pipeable, Predicate, type Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import * as QueryClient from "./QueryClient.js"
import * as Result from "./Result.js"
@@ -6,49 +6,54 @@ import * as Result from "./Result.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
export interface Query<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
readonly key: Stream.Stream<K>
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
readonly staleTime: Duration.DurationInput
readonly refreshOnWindowFocus: boolean
readonly latestKey: Subscribable.Subscribable<Option.Option<K>>
readonly fiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, E>>>
readonly result: Subscribable.Subscribable<Result.Result<A, E, P>>
readonly latestFinalResult: Subscribable.Subscribable<Option.Option<Result.Final<A, E, P>>>
readonly run: Effect.Effect<void>
fetch(key: K): Effect.Effect<Result.Final<A, E, P>>
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>>
readonly refetch: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refetchSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly refresh: Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException>
readonly refreshSubscribable: Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException>
readonly invalidateCache: Effect.Effect<void>
invalidateCacheEntry(key: K): Effect.Effect<void>
}
export declare namespace Query {
export type AnyKey = readonly any[]
}
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Query<K, A, E, R, P> {
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
readonly key: Stream.Stream<K>,
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
readonly staleTime: Duration.DurationInput,
readonly refreshOnWindowFocus: boolean,
readonly latestKey: SubscriptionRef.SubscriptionRef<Option.Option<K>>,
readonly fiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, E>>>,
readonly result: SubscriptionRef.SubscriptionRef<Result.Result<A, E, P>>,
readonly latestFinalResult: SubscriptionRef.SubscriptionRef<Option.Option<Result.Final<A, E, P>>>,
readonly runSemaphore: Effect.Semaphore,
) {
@@ -56,16 +61,27 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
}
get run(): Effect.Effect<void> {
return Stream.runForEach(this.key, key => this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(this.startCached(key, Result.initial(), false)),
Effect.andThen(sub => Effect.forkScoped(this.watch(sub))),
Effect.provide(this.context),
return Effect.all([
Stream.runForEach(this.key, key => this.fetchSubscribable(key)),
Effect.promise(() => import("@effect/platform-browser")).pipe(
Effect.andThen(({ BrowserStream }) => this.refreshOnWindowFocus
? Stream.runForEach(
BrowserStream.fromEventListenerWindow("focus"),
() => this.refreshSubscribable,
)
: Effect.void
),
Effect.catchAllDefect(() => Effect.void),
),
], { concurrency: "unbounded" }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
))
Effect.provide(this.context),
)
}
get interrupt(): Effect.Effect<void, never, never> {
get interrupt(): Effect.Effect<void> {
return Effect.andThen(this.fiber, Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
@@ -75,181 +91,228 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
fetch(key: K): Effect.Effect<Result.Final<A, E, P>> {
return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(Effect.provide(this.startCached(key, Result.initial(), false), this.context)),
Effect.andThen(sub => this.watch(sub)),
Effect.andThen(this.latestFinalResult),
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.andThen(sub => this.watch(key, sub)),
Effect.provide(this.context),
)
}
fetchSubscribable(key: K): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>> {
return this.interrupt.pipe(
Effect.andThen(SubscriptionRef.set(this.latestKey, Option.some(key))),
Effect.andThen(Effect.provide(this.startCached(key, Result.initial(), false), this.context)),
)
}
get refetch(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key, Result.initial(), false), this.context)),
Effect.andThen(sub => this.watch(sub)),
)
}
get refetchSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key, Result.initial(), false), this.context)),
Effect.andThen(this.latestFinalResult),
Effect.andThen(previous => this.startCached(key, Option.isSome(previous)
? Result.willFetch(previous.value) as Result.Final<A, E, P>
: Result.initial()
)),
Effect.tap(sub => Effect.forkScoped(this.watch(key, sub))),
Effect.provide(this.context),
)
}
get refresh(): Effect.Effect<Result.Final<A, E, P>, Cause.NoSuchElementException> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key, Result.initial(), true), this.context)),
Effect.andThen(sub => this.watch(sub)),
Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.andThen(({ latestKey, subscribable }) => this.watch(latestKey, subscribable)),
Effect.provide(this.context),
)
}
get refreshSubscribable(): Effect.Effect<Subscribable.Subscribable<Result.Result<A, E, P>>, Cause.NoSuchElementException> {
get refreshSubscribable(): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
Cause.NoSuchElementException
> {
return this.interrupt.pipe(
Effect.andThen(this.latestKey),
Effect.andThen(identity),
Effect.andThen(key => Effect.provide(this.start(key, Result.initial(), true), this.context)),
Effect.andThen(Effect.Do),
Effect.bind("latestKey", () => Effect.andThen(this.latestKey, identity)),
Effect.bind("latestFinalResult", () => this.latestFinalResult),
Effect.bind("subscribable", ({ latestKey, latestFinalResult }) =>
this.startCached(latestKey, Option.isSome(latestFinalResult)
? Result.willRefresh(latestFinalResult.value) as Result.Final<A, E, P>
: Result.initial()
)
),
Effect.tap(({ latestKey, subscribable }) => Effect.forkScoped(this.watch(latestKey, subscribable))),
Effect.map(({ subscribable }) => subscribable),
Effect.provide(this.context),
)
}
startCached(
key: K,
initial: Result.Result<A, E, P>,
refresh: boolean,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | QueryClient.QueryClient | R
> {
return Effect.andThen(this.getCacheEntry(key), Option.match({
onSome: entry => QueryClient.isQueryClientCacheEntryStale(entry, this.staleTime)
? Effect.map(
SubscriptionRef.set(this.result, entry.result as Result.Result<A, E, P>),
() => Subscribable.make({
onSome: entry => Effect.andThen(
QueryClient.isQueryClientCacheEntryStale(entry),
isStale => isStale
? this.start(key, Result.willRefresh(entry.result) as Result.Final<A, E, P>)
: Effect.succeed(Subscribable.make({
get: Effect.succeed(entry.result as Result.Result<A, E, P>),
get changes() { return Stream.empty },
}),
)
: this.start(key, Result.optimistic(entry.result) as Result.Result<A, E, P>, false),
onNone: () => this.start(key, initial, refresh),
get changes() { return Stream.make(entry.result as Result.Result<A, E, P>) },
})),
),
onNone: () => this.start(key, initial),
}))
}
start(
key: K,
initial: Result.Result<A, E, P>,
refresh: boolean,
initial: Result.Initial | Result.Final<A, E, P>,
): Effect.Effect<
Subscribable.Subscribable<Result.Result<A, E, P>>,
never,
Scope.Scope | QueryClient.QueryClient | R
Scope.Scope | R
> {
return this.result.pipe(
Effect.map(previous => Result.isFinal(previous) ? previous : undefined),
Effect.andThen(previous => Result.unsafeForkEffect(
Effect.onExit(this.f(key), exit => Effect.andThen(
Exit.isSuccess(exit)
? this.updateCacheEntry(key, Result.succeed(exit.value))
return Result.unsafeForkEffect(
Effect.onExit(this.f(key), () => Effect.andThen(
Effect.all([Effect.fiberId, this.fiber]),
([currentFiberId, fiber]) => Option.match(fiber, {
onSome: v => Equal.equals(currentFiberId, v.id())
? SubscriptionRef.set(this.fiber, Option.none())
: Effect.void,
SubscriptionRef.set(this.fiber, Option.none()),
)),
{
initial,
initialProgress: this.initialProgress,
refresh: refresh && previous,
previous,
} as Result.unsafeForkEffect.Options<A, E, P>,
onNone: () => Effect.void,
}),
)),
{
initial,
initialProgress: this.initialProgress,
} as Result.unsafeForkEffect.Options<A, E, P>,
).pipe(
Effect.tap(([, fiber]) => SubscriptionRef.set(this.fiber, Option.some(fiber))),
Effect.map(([sub]) => sub),
)
}
watch(
key: K,
sub: Subscribable.Subscribable<Result.Result<A, E, P>>
): Effect.Effect<Result.Final<A, E, P>> {
return Effect.andThen(
sub.get,
initial => Stream.runFoldEffect(
Stream.filter(sub.changes, Predicate.not(Result.isInitial)),
): Effect.Effect<Result.Final<A, E, P>, never, QueryClient.QueryClient> {
return sub.get.pipe(
Effect.andThen(initial => Stream.runFoldEffect(
sub.changes,
initial,
(_, result) => Effect.as(SubscriptionRef.set(this.result, result), result),
) as Effect.Effect<Result.Final<A, E, P>>),
Effect.tap(result => SubscriptionRef.set(this.latestFinalResult, Option.some(result))),
Effect.tap(result => Result.isSuccess(result)
? this.setCacheEntry(key, result)
: Effect.void
),
) as Effect.Effect<Result.Final<A, E, P>>
)
}
makeCacheKey(key: K): QueryClient.QueryClientCacheKey {
return new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)
}
getCacheEntry(
key: K
): Effect.Effect<Option.Option<QueryClient.QueryClientCacheEntry>, never, QueryClient.QueryClient> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => client.cache),
Effect.map(HashMap.get(new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>))),
return Effect.andThen(
Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.getCacheEntry(key),
)
}
updateCacheEntry(
setCacheEntry(
key: K,
result: Result.Success<A>,
): Effect.Effect<QueryClient.QueryClientCacheEntry, never, QueryClient.QueryClient> {
return Effect.Do.pipe(
Effect.bind("client", () => QueryClient.QueryClient),
Effect.bind("now", () => DateTime.now),
Effect.let("entry", ({ now }) => new QueryClient.QueryClientCacheEntry(result, now)),
Effect.tap(({ client, entry }) => SubscriptionRef.update(
client.cache,
HashMap.set(new QueryClient.QueryClientCacheKey(key, this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>), entry),
)),
Effect.map(({ entry }) => entry),
return Effect.andThen(
Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]),
([key, client]) => client.setCacheEntry(key, result, this.staleTime),
)
}
get invalidateCache(): Effect.Effect<void> {
return QueryClient.QueryClient.pipe(
Effect.andThen(client => client.invalidateCacheEntries(this.f as (key: Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>)),
Effect.provide(this.context),
)
}
invalidateCacheEntry(key: K): Effect.Effect<void> {
return Effect.all([
Effect.succeed(this.makeCacheKey(key)),
QueryClient.QueryClient,
]).pipe(
Effect.andThen(([key, client]) => client.invalidateCacheEntry(key)),
Effect.provide(this.context),
)
}
}
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make {
export interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> {
readonly key: Stream.Stream<K>
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
readonly staleTime?: Duration.DurationInput
readonly refreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.fn.Return<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> {
const client = yield* QueryClient.QueryClient
return new QueryImpl(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(),
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
options.key,
options.f as any,
options.initialProgress as P,
options.staleTime ?? client.defaultStaleTime,
options.refreshOnWindowFocus ?? client.defaultRefreshOnWindowFocus,
yield* SubscriptionRef.make(Option.none<K>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, E>>()),
yield* SubscriptionRef.make(Result.initial<A, E, P>()),
yield* SubscriptionRef.make(Option.none<Result.Final<A, E, P>>()),
yield* Effect.makeSemaphore(1),
)
})
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.Effect<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run),
+71 -13
View File
@@ -1,16 +1,28 @@
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, Pipeable, Predicate, type Scope, SubscriptionRef } from "effect"
import { DateTime, Duration, Effect, Equal, Equivalence, Hash, HashMap, type Option, Pipeable, Predicate, Schedule, type Scope, type Subscribable, SubscriptionRef } from "effect"
import type * as Query from "./Query.js"
import type * as Result from "./Result.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientServiceTypeId")
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
readonly [QueryClientServiceTypeId]: QueryClientServiceTypeId
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly gcTime: Duration.DurationInput
readonly cache: Subscribable.Subscribable<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>
readonly cacheGcTime: Duration.DurationInput
readonly defaultStaleTime: Duration.DurationInput
readonly defaultRefreshOnWindowFocus: boolean
readonly run: Effect.Effect<void>
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>>
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry>
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void>
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void>
}
export class QueryClient extends Effect.Service<QueryClient>()("@effect-fc/QueryClient/QueryClient", {
@@ -24,41 +36,86 @@ implements QueryClientService {
constructor(
readonly cache: SubscriptionRef.SubscriptionRef<HashMap.HashMap<QueryClientCacheKey, QueryClientCacheEntry>>,
readonly gcTime: Duration.DurationInput,
readonly cacheGcTime: Duration.DurationInput,
readonly defaultStaleTime: Duration.DurationInput,
readonly defaultRefreshOnWindowFocus: boolean,
readonly runSemaphore: Effect.Semaphore,
) {
super()
}
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Effect.repeat(
Effect.andThen(
DateTime.now,
now => SubscriptionRef.update(this.cache, HashMap.filter(entry =>
Duration.lessThan(
DateTime.distanceDuration(entry.lastAccessedAt, now),
Duration.sum(entry.staleTime, this.cacheGcTime),
)
)),
),
Schedule.spaced("30 second"),
))
}
getCacheEntry(key: QueryClientCacheKey): Effect.Effect<Option.Option<QueryClientCacheEntry>> {
return Effect.all([
Effect.andThen(this.cache, HashMap.get(key)),
DateTime.now,
]).pipe(
Effect.map(([entry, now]) => new QueryClientCacheEntry(entry.result, entry.staleTime, entry.createdAt, now)),
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
Effect.option,
)
}
setCacheEntry(
key: QueryClientCacheKey,
result: Result.Success<unknown>,
staleTime: Duration.DurationInput,
): Effect.Effect<QueryClientCacheEntry> {
return DateTime.now.pipe(
Effect.map(now => new QueryClientCacheEntry(result, staleTime, now, now)),
Effect.tap(entry => SubscriptionRef.update(this.cache, HashMap.set(key, entry))),
)
}
invalidateCacheEntries(f: (key: Query.Query.AnyKey) => Effect.Effect<unknown, unknown, unknown>): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.filter((_, key) => !Equivalence.strict()(key.f, f)))
}
invalidateCacheEntry(key: QueryClientCacheKey): Effect.Effect<void> {
return SubscriptionRef.update(this.cache, HashMap.remove(key))
}
}
export const isQueryClientService = (u: unknown): u is QueryClientService => Predicate.hasProperty(u, QueryClientServiceTypeId)
export declare namespace make {
export interface Options {
readonly gcTime?: Duration.DurationInput
readonly cacheGcTime?: Duration.DurationInput
readonly defaultStaleTime?: Duration.DurationInput
readonly defaultRefreshOnWindowFocus?: boolean
}
}
export const make = Effect.fnUntraced(function* (options: make.Options = {}): Effect.fn.Return<QueryClientService> {
return new QueryClientServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<QueryClientCacheKey, QueryClientCacheEntry>()),
options.gcTime ?? "5 minutes",
options.cacheGcTime ?? "5 minutes",
options.defaultStaleTime ?? "0 minutes",
options.defaultRefreshOnWindowFocus ?? true,
yield* Effect.makeSemaphore(1),
)
})
export const run = (_self: QueryClientService): Effect.Effect<void> => Effect.void
export declare namespace service {
export interface Options extends make.Options {}
}
export const service = (options?: service.Options): Effect.Effect<QueryClientService, never, Scope.Scope> => Effect.tap(
make(options),
client => Effect.forkScoped(run(client)),
client => Effect.forkScoped(client.run),
)
@@ -98,7 +155,9 @@ implements Pipeable.Pipeable {
constructor(
readonly result: Result.Success<unknown>,
readonly staleTime: Duration.DurationInput,
readonly createdAt: DateTime.DateTime,
readonly lastAccessedAt: DateTime.DateTime,
) {
super()
}
@@ -107,9 +166,8 @@ implements Pipeable.Pipeable {
export const isQueryClientCacheEntry = (u: unknown): u is QueryClientCacheEntry => Predicate.hasProperty(u, QueryClientCacheEntryTypeId)
export const isQueryClientCacheEntryStale = (
self: QueryClientCacheEntry,
staleTime: Duration.DurationInput,
self: QueryClientCacheEntry
): Effect.Effect<boolean> => Effect.andThen(
DateTime.now,
now => Duration.lessThan(DateTime.distanceDuration(self.createdAt, now), staleTime),
now => Duration.greaterThanOrEqualTo(DateTime.distanceDuration(self.createdAt, now), self.staleTime),
)
+139 -171
View File
@@ -1,110 +1,102 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Option, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } from "effect"
import { Lens } from "effect-lens"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
export type ResultTypeId = typeof ResultTypeId
export type Result<A, E = never, P = never> = (
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
| (Initial & ({} | WillFetch))
| Initial
| Running<P>
| Final<A, E, P>
)
export type Final<A, E = never, P = never> = (
& (Success<A> | Failure<A, E>)
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
& ({} | WillFetch | WillRefresh | Refreshing<P>)
)
export namespace Result {
export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
// biome-ignore lint/complexity/noBannedTypes: "{}" is relevant here
export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | Flags<P>)
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result {
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
}
export interface Initial extends Result.Prototype {
export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends ResultPrototype {
readonly _tag: "Initial"
}
export interface Running<P = never> extends Result.Prototype {
export interface Running<P = never> extends ResultPrototype {
readonly _tag: "Running"
readonly progress: P
}
export interface Success<A> extends Result.Prototype {
export interface Success<A> extends ResultPrototype {
readonly _tag: "Success"
readonly value: A
}
export interface Failure<A, E = never> extends Result.Prototype {
export interface Failure<E = never> extends ResultPrototype {
readonly _tag: "Failure"
readonly cause: Cause.Cause<E>
readonly previousSuccess: Option.Option<Success<A>>
}
export interface WillFetch extends Result.Prototype {
readonly willFetch: true
export interface WillFetch {
readonly _flag: "WillFetch"
}
export interface WillRefresh extends Result.Prototype {
readonly willRefresh: true
export interface WillRefresh {
readonly _flag: "WillRefresh"
}
export interface Refreshing<P = never> extends Result.Prototype {
readonly refreshing: true
export interface Refreshing<P = never> {
readonly _flag: "Refreshing"
readonly progress: P
}
const ResultPrototype = Object.freeze({
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId,
[Equal.symbol](this: Result<any, any, any>, that: Result<any, any, any>): boolean {
if (this._tag !== that._tag)
if (this._tag !== that._tag || (this as Flags)._flag !== (that as Flags)._flag)
return false
if (hasRefreshingFlag(this) && !Equal.equals(this.progress, (that as Refreshing<any>).progress))
return false
return Match.value(this).pipe(
Match.tag("Initial", () => true),
Match.tag("Running", self => Equal.equals(self.progress, (that as Running<any>).progress)),
Match.tag("Success", self =>
Equal.equals(self.value, (that as Success<any>).value) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.tag("Failure", self =>
Equal.equals(self.cause, (that as Failure<any, any>).cause) &&
(isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) &&
Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined)
),
Match.tag("Success", self => Equal.equals(self.value, (that as Success<any>).value)),
Match.tag("Failure", self => Equal.equals(self.cause, (that as Failure<any>).cause)),
Match.exhaustive,
)
},
[Hash.symbol](this: Result<any, any, any>): number {
const tagHash = Hash.string(this._tag)
return Match.value(this).pipe(
Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => pipe(tagHash,
Hash.combine(Hash.hash(self.value)),
Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.tag("Failure", self => pipe(tagHash,
Hash.combine(Hash.hash(self.cause)),
Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)),
)),
Match.exhaustive,
return pipe(Hash.string(this._tag),
tagHash => Match.value(this).pipe(
Match.tag("Initial", () => tagHash),
Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)),
Match.tag("Success", self => Hash.combine(Hash.hash(self.value))(tagHash)),
Match.tag("Failure", self => Hash.combine(Hash.hash(self.cause))(tagHash)),
Match.exhaustive,
),
Hash.combine(Hash.hash((this as Flags)._flag)),
hash => hasRefreshingFlag(this)
? Hash.combine(Hash.hash(this.progress))(hash)
: hash,
Hash.cached(this),
)
},
} as const satisfies Result.Prototype)
} as const)
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
@@ -112,10 +104,11 @@ export const isFinal = (u: unknown): u is Final<unknown, unknown, unknown> => is
export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial"
export const isRunning = (u: unknown): u is Running<unknown> => isResult(u) && u._tag === "Running"
export const isSuccess = (u: unknown): u is Success<unknown> => isResult(u) && u._tag === "Success"
export const isFailure = (u: unknown): u is Failure<unknown, unknown> => isResult(u) && u._tag === "Failure"
export const isWillFetch = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "willFetch")
export const isWillRefresh = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "willRefresh")
export const isRefreshing = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "refreshing")
export const isFailure = (u: unknown): u is Failure<unknown> => isResult(u) && u._tag === "Failure"
export const hasFlag = (u: unknown): u is Flags => isResult(u) && Predicate.hasProperty(u, "_flag")
export const hasWillFetchFlag = (u: unknown): u is WillFetch => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillFetch"
export const hasWillRefreshFlag = (u: unknown): u is WillRefresh => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "WillRefresh"
export const hasRefreshingFlag = (u: unknown): u is Refreshing<unknown> => isResult(u) && Predicate.hasProperty(u, "_flag") && u._flag === "Refreshing"
export const initial: {
(): Initial
@@ -123,48 +116,42 @@ export const initial: {
} = (): Initial => Object.setPrototypeOf({ _tag: "Initial" }, ResultPrototype)
export const running = <P = never>(progress?: P): Running<P> => Object.setPrototypeOf({ _tag: "Running", progress }, ResultPrototype)
export const succeed = <A>(value: A): Success<A> => Object.setPrototypeOf({ _tag: "Success", value }, ResultPrototype)
export const fail = <E>(cause: Cause.Cause<E> ): Failure<E> => Object.setPrototypeOf({ _tag: "Failure", cause }, ResultPrototype)
export const fail = <E, A = never>(
cause: Cause.Cause<E>,
previousSuccess?: Success<NoInfer<A>>,
): Failure<A, E> => Object.setPrototypeOf({
_tag: "Failure",
cause,
previousSuccess: Option.fromNullable(previousSuccess),
}, ResultPrototype)
export const willFetch = <R extends Initial | Final<any, any, any>>(
export const willFetch = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof WillFetch> & WillFetch => Object.setPrototypeOf(
Object.assign({}, result, { willFetch: true }),
): Omit<R, keyof Flags.Keys> & WillFetch => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillFetch" }),
Object.getPrototypeOf(result),
)
export const willRefresh = <R extends Final<any, any, any>>(
result: R
): Omit<R, keyof WillRefresh> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { willRefresh: true }),
): Omit<R, keyof Flags.Keys> & WillRefresh => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "WillRefresh" }),
Object.getPrototypeOf(result),
)
export const refreshing = <R extends Final<any, any, any>, P = never>(
result: R,
progress?: P,
): Omit<R, keyof Refreshing<Result.Progress<R>>> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { refreshing: true, progress }),
): Omit<R, keyof Flags.Keys> & Refreshing<P> => Object.setPrototypeOf(
Object.assign({}, result, { _flag: "Refreshing", progress }),
Object.getPrototypeOf(result),
)
export const fromExit = <A, E>(
exit: Exit.Exit<A, E>,
previousSuccess?: Success<NoInfer<A>>,
): Success<A> | Failure<A, E> => exit._tag === "Success"
? succeed(exit.value)
: fail(exit.cause, previousSuccess)
export const fromExit: {
<A, E>(exit: Exit.Success<A, E>): Success<A>
<A, E>(exit: Exit.Failure<A, E>): Failure<E>
<A, E>(exit: Exit.Exit<A, E>): Success<A> | Failure<E>
} = exit => (exit._tag === "Success" ? succeed(exit.value) : fail(exit.cause)) as any
export const toExit = <A, E, P>(
self: Result<A, E, P>
): Exit.Exit<A, E | Cause.NoSuchElementException> => {
export const toExit: {
<A>(self: Success<A>): Exit.Success<A, never>
<E>(self: Failure<E>): Exit.Failure<never, E>
<A, E, P>(self: Final<A, E, P>): Exit.Exit<A, E>
<A, E, P>(self: Result<A, E, P>): Exit.Exit<A, E | Cause.NoSuchElementException>
} = <A, E, P>(self: Result<A, E, P>): any => {
switch (self._tag) {
case "Success":
return Exit.succeed(self.value)
@@ -176,117 +163,98 @@ export const toExit = <A, E, P>(
}
export interface State<A, E = never, P = never> {
readonly get: Effect.Effect<Result<A, E, P>>
readonly set: (v: Result<A, E, P>) => Effect.Effect<void>
}
export const State = <A, E = never, P = never>(): Context.Tag<State<A, E, P>, State<A, E, P>> => Context.GenericTag("@effect-fc/Result/State")
export interface Progress<P = never> {
readonly update: <E, R>(
f: (previous: P) => Effect.Effect<P, E, R>
) => Effect.Effect<void, PreviousResultNotRunningNorRefreshing | E, R>
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
}
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
readonly previous: Result<unknown, unknown, unknown>
}> {}
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
Progress<P>,
never,
State<A, E, P>
> => Layer.effect(Progress<P>(), Effect.gen(function*() {
const state = yield* State<A, E, P>()
return {
update: <E, R>(f: (previous: P) => Effect.Effect<P, E, R>) => Effect.Do.pipe(
Effect.bind("previous", () => Effect.andThen(state.get, previous =>
isRunning(previous) || isRefreshing(previous)
? Effect.succeed(previous)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
)),
Effect.bind("progress", ({ previous }) => f(previous.progress)),
Effect.let("next", ({ previous, progress }) => Object.setPrototypeOf(
Object.assign({}, previous, { progress }),
Object.getPrototypeOf(previous),
)),
Effect.andThen(({ next }) => state.set(next)),
),
}
}))
export const makeProgressLayer = <A, E, P = never>(
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
{
progress: state.pipe(
Lens.mapEffect(
a => (isRunning(a) || hasRefreshingFlag(a))
? Effect.succeed(a)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
(_, b) => Effect.succeed(b),
),
Lens.map(
a => a.progress,
(a, b) => isRunning(a)
? running(b)
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
),
)
},
)
export namespace unsafeForkEffect {
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
export type Options<A, E, P> = {
readonly initial?: Result<A, E, P>
export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
readonly initialProgress?: P
readonly previous?: Final<A, E, P>
} & (
| {
readonly refresh: true
readonly previous: Final<A, E, P>
}
| {
readonly refresh?: false
}
)
}
}
export const unsafeForkEffect = <A, E, R, P = never>(
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
effect: Effect.Effect<A, E, R>,
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect<
): Effect.fn.Return<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
> => Effect.Do.pipe(
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
Effect.andThen(state => state.set(options?.refresh
? refreshing(options.previous, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress)
).pipe(
Effect.andThen(effect),
Effect.onExit(exit => Effect.andThen(
state.set(fromExit(exit, (options?.previous && isSuccess(options.previous)) ? options.previous : undefined)),
Effect.forkScoped(PubSub.shutdown(pubsub)),
)),
)),
Effect.provide(Layer.empty.pipe(
Layer.provideMerge(makeProgressLayer<A, E, P>()),
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: ref,
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v))
})),
)),
))),
Effect.map(({ ref, pubsub, fiber }) => [
Subscribable.make({
get: ref,
changes: Stream.unwrapScoped(Effect.map(
Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]),
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
> {
const ref = (yield* SynchronizedRef.make(
options?.initial ?? initial<A, E, P>()
)) as Lens.SynchronizedRefLensImpl.SynchronizedRefWithInternals<Result<A, E, P>>
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
const state = Lens.make({
get: Ref.get(ref.ref),
get changes() {
return Stream.unwrapScoped(Effect.map(
Effect.all([Ref.get(ref.ref), Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
)),
}),
fiber,
]),
) as Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
>
))
},
commit: value => Effect.zipLeft(
Ref.set(ref.ref, value),
PubSub.publish(pubsub, value),
),
lock: Effect.succeed(ref.withLock),
})
const fiber = yield* Effect.gen(function*() {
yield* Lens.set(
state,
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress),
)
return yield* Effect.onExit(effect, exit => Effect.andThen(
Lens.set(state, fromExit(exit)),
Effect.forkScoped(PubSub.shutdown(pubsub)),
))
}).pipe(
Effect.forkScoped,
Effect.provide(makeProgressLayer(state)),
)
return [state, fiber] as const
})
export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
export type Options<A, E, P> = unsafeForkEffect.Options<A, E, P>
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
}
export const forkEffect: {
@@ -296,6 +264,6 @@ export const forkEffect: {
): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | forkEffect.OutputContext<A, E, R, P>
Scope.Scope | forkEffect.OutputContext<R, P>
>
} = unsafeForkEffect
+1 -1
View File
@@ -3,8 +3,8 @@ import type * as React from "react"
export const value: {
<S>(prevState: S): (self: React.SetStateAction<S>) => S
<S>(self: React.SetStateAction<S>, prevState: S): S
<S>(prevState: S): (self: React.SetStateAction<S>) => S
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
typeof self === "function"
? (self as (prevState: S) => S)(prevState)
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react"
import * as Component from "./Component.js"
export const useStream: {
export const use: {
<A, E, R>(
stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R>
+220
View File
@@ -0,0 +1,220 @@
import { Array, Cause, Chunk, type Context, Effect, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, SubscriptionRef } from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Mutation from "./Mutation.js"
import * as Result from "./Result.js"
import * as Subscribable from "./Subscribable.js"
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Form.Form<readonly [], A, I, never, never> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
}
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class() implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
this.encodedValue = Effect.all([
Effect.succeed(this),
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
]).pipe(
Effect.map(([self, parent]) => Lens.make({
get: parent.get,
get changes() { return parent.changes },
commit: a => Effect.andThen(
Effect.flatMap(
parent.resolve,
resolved => resolved.commit(Effect.succeed(a)),
),
self.synchronizeEncodedValue(a),
),
lock: parent.lock,
})),
Lens.unwrap,
)
this.isValidating = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
Subscribable.unwrap,
)
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(
Subscribable.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.result),
([value, issues, validationFiber, result]) => (
Option.isSome(value) &&
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
),
)),
Subscribable.unwrap,
)
this.isCommitting = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(
self.mutation.result,
result => Result.isRunning(result) || Result.hasRefreshingFlag(result),
)),
Subscribable.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
return Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(Effect.forkScoped(
Effect.ensuring(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
Lens.set(this.validationFiber, Option.none()),
)
)),
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.flatMap(Fiber.join),
Effect.tap(() => Lens.set(this.issues, Array.empty())),
Effect.flatMap(value => Lens.set(this.value, Option.some(value))),
Effect.catchIf(
ParseResult.isParseError,
flow(
ParseResult.ArrayFormatter.formatError,
Effect.flatMap(v => Lens.set(this.issues, v)),
),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, never, never> {
return Lens.get(this.encodedValue).pipe(
Effect.flatMap(v => Schema.decode(this.schema)(v)),
Effect.option,
Effect.flatMap(v => Lens.set(this.value, v)),
Effect.provide(this.context),
this.runSemaphore.withPermits(1),
)
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException, never> {
return Lens.get(this.value).pipe(
Effect.flatMap(identity),
Effect.flatMap(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, never, never> {
return Effect.whenEffect(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => Result.isFailure(result)
? Option.match(
Chunk.findFirst(
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
e => e._tag === "ParseError",
),
{
onSome: e => Effect.flatMap(
ParseResult.ArrayFormatter.formatError(e),
v => Lens.set(this.issues, v),
),
onNone: () => Effect.void,
},
)
: Effect.void
),
this.canCommit.get,
)
}
}
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>
): Effect.fn.Return<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> {
return new SubmittableFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>
): Effect.Effect<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
@@ -0,0 +1,129 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import { describe, expect, it } from "vitest"
import * as Component from "./Component.js"
import * as ReactRuntime from "./ReactRuntime.js"
import * as Subscribable from "./Subscribable.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
return {
runtime,
effectRuntime,
dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
}
}
describe("Subscribable", () => {
it("zipLatestAll reads current values from all inputs", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
})
it("zipLatestAll emits updates when any input changes", async () => {
const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
const left = Lens.fromSubscriptionRef(leftRef)
const right = Lens.fromSubscriptionRef(rightRef)
const zipped = Subscribable.zipLatestAll(left, right)
const values: Array<readonly [number, string]> = []
const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
Stream.runForEach(value => Effect.sync(() => {
values.push(value as readonly [number, string])
})),
)))
await Effect.runPromise(Lens.set(left, 2))
await waitFor(() => expect(values).toContainEqual([2, "a"]))
await Effect.runPromise(Lens.set(right, "b"))
await waitFor(() => expect(values).toContainEqual([2, "b"]))
Fiber.interruptFork(collector)
})
it("useAll returns the latest values and rerenders when any input changes", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const countRef = await Effect.runPromise(SubscriptionRef.make(1))
const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
const count = Lens.fromSubscriptionRef(countRef)
const label = Lens.fromSubscriptionRef(labelRef)
const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* Subscribable.useAll([count, label])
return <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("1:a")
await Effect.runPromise(Lens.set(count, 2))
await screen.findByText("2:a")
await Effect.runPromise(Lens.set(label, "b"))
await screen.findByText("2:b")
view.unmount()
await dispose()
})
it("useAll respects the provided equivalence when processing updates", async () => {
const { runtime, effectRuntime, dispose } = await makeRuntime()
const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
const item = Lens.fromSubscriptionRef(itemRef)
const flag = Lens.fromSubscriptionRef(flagRef)
const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
const [currentItem, currentFlag] = yield* Subscribable.useAll([item, flag], {
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
selfItem.id === thatItem.id && selfFlag === thatFlag,
})
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
}).pipe(
Component.withRuntime(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("first:on")
await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
expect(screen.queryByText("ignored:on")).toBeNull()
await Effect.runPromise(Lens.set(flag, false))
await screen.findByText("ignored:off")
await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
await screen.findByText("updated:off")
view.unmount()
await dispose()
})
})
+8 -7
View File
@@ -1,8 +1,11 @@
import { Effect, Equivalence, Stream, Subscribable } from "effect"
import { Effect, Equivalence, Stream } from "effect"
import { Subscribable } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
export * from "effect-lens/Subscribable"
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Subscribable.Subscribable<
@@ -16,7 +19,7 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any
export declare namespace useSubscribables {
export declare namespace useAll {
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
@@ -26,11 +29,11 @@ export declare namespace useSubscribables {
}
}
export const useSubscribables = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
elements: T,
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>,
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return<
useSubscribables.Success<T>,
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> {
@@ -48,5 +51,3 @@ export const useSubscribables = Effect.fnUntraced(function* <const T extends rea
return reactStateValue as any
})
export * from "effect/Subscribable"
-61
View File
@@ -1,61 +0,0 @@
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
export declare namespace useSubscriptionRefState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useSubscriptionRefState = Effect.fnUntraced(function* <A>(
ref: SubscriptionRef.SubscriptionRef<A>,
options?: useSubscriptionRefState.Options<NoInfer<A>>,
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>]> {
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
yield* Component.useReactEffect(() => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
)
), [ref])
const setValue = yield* Component.useCallbackSync(
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[ref],
)
return [reactStateValue, setValue]
})
export declare namespace useSubscriptionRefFromState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useSubscriptionRefFromState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useSubscriptionRefFromState.Options<NoInfer<A>>,
): Effect.fn.Return<SubscriptionRef.SubscriptionRef<A>> {
const ref = yield* Component.useOnChange(() => Effect.tap(
SubscriptionRef.make(value),
ref => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setValue(v)),
)
),
), [setValue])
yield* Component.useReactEffect(() => Ref.set(ref, value), [value])
return ref
})
export * from "effect/SubscriptionRef"
@@ -1,186 +0,0 @@
import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
import * as PropertyPath from "./PropertyPath.js"
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef")
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
readonly parent: B
readonly [Unify.typeSymbol]?: unknown
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
}
export declare namespace SubscriptionSubRef {
export interface Variance<in out A, in out B> {
readonly [SubscriptionSubRefTypeId]: {
readonly _A: Types.Invariant<A>
readonly _B: Types.Invariant<B>
}
}
}
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
}
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
SubscriptionRef?: true
}
const refVariance = { _A: (_: any) => _ }
const synchronizedRefVariance = { _A: (_: any) => _ }
const subscriptionRefVariance = { _A: (_: any) => _ }
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
class SubscriptionSubRefImpl<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
readonly [Ref.RefTypeId] = refVariance
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
readonly get: Effect.Effect<A>
constructor(
readonly parent: B,
readonly getter: (parentValue: Effect.Effect.Success<B>) => A,
readonly setter: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>,
) {
super()
this.get = Effect.map(this.parent, this.getter)
}
commit() {
return this.get
}
get changes(): Stream.Stream<A> {
return Stream.unwrap(
Effect.map(this.get, a => Stream.concat(
Stream.make(a),
Stream.map(this.parent.changes, this.getter),
))
)
}
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
return this.modifyEffect(a => Effect.succeed(f(a)))
}
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
return Effect.Do.pipe(
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
Effect.bind("ca", ({ b }) => f(this.getter(b))),
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
Effect.map(({ ca: [c] }) => c),
)
}
}
export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef<unknown, SubscriptionRef.SubscriptionRef<unknown>> => Predicate.hasProperty(u, SubscriptionSubRefTypeId)
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
parent: B,
options: {
readonly get: (parentValue: Effect.Effect.Success<B>) => A
readonly set: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>
},
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, options.get, options.set)
export const makeFromPath = <
B extends SubscriptionRef.SubscriptionRef<any>,
const P extends PropertyPath.Paths<Effect.Effect.Success<B>>,
>(
parent: B,
path: P,
): SubscriptionSubRef<PropertyPath.ValueFromPath<Effect.Effect.Success<B>, P>, B> => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
)
export const makeFromChunkIndex: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>,
index: number,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Chunk.unsafeGet(parentValue, index),
(parentValue, value) => Chunk.replace(parentValue, index, value),
) as any
export const makeFromChunkFindFirst: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findFirstPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findFirstIndex(parentValue, findFirstPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any
export const makeFromChunkFindLast: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findLastPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findLastIndex(parentValue, findLastPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any
+223
View File
@@ -0,0 +1,223 @@
import { Array, type Context, Effect, Equal, Fiber, flow, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream, SubscriptionRef } from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm")
export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId
export interface SynchronizedForm<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Form.Form<readonly [], A, I, TER, TER | TEW> {
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly run: Effect.Effect<void, TER>
}
export class SynchronizedFormImpl<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Pipeable.Class() implements SynchronizedForm<A, I, R, TER, TEW, TRR, TRW> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
readonly value: Subscribable.Subscribable<Option.Option<A>, never, never>
readonly encodedValue: Lens.Lens<I, TER, TER | TEW, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>,
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
readonly isCommitting: Lens.Lens<boolean, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
this.value = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.make({
get: Effect.provide(Effect.option(self.target.get), self.context),
get changes() {
return Stream.provideContext(
self.target.changes.pipe(
Stream.map(Option.some),
Stream.catchAll(() => Stream.make(Option.none())),
),
self.context,
)
},
})),
Subscribable.unwrap,
)
this.encodedValue = Effect.all([
Effect.succeed(this),
Effect.succeed(Lens.asLensImpl(this.internalEncodedValue)),
]).pipe(
Effect.map(([self, parent]) => Lens.make<I, TER, TER | TEW, never, never>({
get: parent.get,
get changes() { return parent.changes },
commit: a => Effect.andThen(
Effect.flatMap(
parent.resolve,
resolved => resolved.commit(Effect.succeed(a)),
),
self.synchronizeEncodedValue(a),
),
lock: parent.lock,
})),
Lens.unwrap,
)
this.isValidating = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)),
Subscribable.unwrap,
)
this.canCommit = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.map(
Subscribable.zipLatestAll(self.issues, self.validationFiber, self.isCommitting),
([issues, validationFiber, isCommitting]) => (
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber) &&
!isCommitting
),
)),
Subscribable.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
return Lens.get(this.validationFiber).pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(Effect.forkScoped(
Effect.ensuring(
Schema.decode(this.schema, { errors: "all" })(encodedValue),
Lens.set(this.validationFiber, Option.none()),
)
)),
Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))),
Effect.flatMap(Fiber.join),
Effect.flatMap(value => Effect.ensuring(
Lens.set(this.isCommitting, true).pipe(
Effect.andThen(Lens.set(this.issues, Array.empty())),
Effect.andThen(Lens.set(this.target, value)),
),
Lens.set(this.isCommitting, false),
)),
Effect.catchIf(
ParseResult.isParseError,
flow(
ParseResult.ArrayFormatter.formatError,
Effect.flatMap(v => Lens.set(this.issues, v)),
),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, TER, never> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Stream.runForEach(
Stream.drop(this.target.changes, 1),
targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe(
Effect.flatMap(encodedValue => Effect.whenEffect(
Effect.andThen(
Lens.set(this.issues, Array.empty()),
Lens.set(this.internalEncodedValue, encodedValue),
),
Effect.map(
Lens.get(this.internalEncodedValue),
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
),
)),
Effect.ignore,
),
),
this.context,
))
}
}
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never> {
readonly schema: Schema.Schema<A, I, R>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly initialEncodedValue?: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: make.Options<A, I, R, TER, TEW, TRR, TRW>
): Effect.fn.Return<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
ParseResult.ParseError | TER,
Scope.Scope | R | TRR | TRW
> {
const initialEncodedValue = options.initialEncodedValue !== undefined
? options.initialEncodedValue
: yield* Effect.flatMap(
Lens.get(options.target),
Schema.encode(options.schema, { errors: "all" }),
)
return new SynchronizedFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
options.target,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never>
extends make.Options<A, I, R, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, R, TER, TEW, TRR, TRW>
): Effect.Effect<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
ParseResult.ParseError | TER,
Scope.Scope | R | TRR | TRW
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
+3 -3
View File
@@ -2,9 +2,9 @@ export * as Async from "./Async.js"
export * as Component from "./Component.js"
export * as ErrorObserver from "./ErrorObserver.js"
export * as Form from "./Form.js"
export * as Lens from "./Lens.js"
export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js"
export * as PropertyPath from "./PropertyPath.js"
export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js"
@@ -12,6 +12,6 @@ export * as ReactRuntime from "./ReactRuntime.js"
export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js"
export * as Subscribable from "./Subscribable.js"
export * as SubscriptionRef from "./SubscriptionRef.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
export * as SynchronizedForm from "./SynchronizedForm.js"
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/setup-tests.ts", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"]
}
+2 -1
View File
@@ -25,6 +25,7 @@
"noPropertyAccessFromIndexSignature": false,
// Build
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
@@ -34,5 +35,5 @@
]
},
"include": ["./src"]
"include": ["./src"],
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "jsdom",
include: ["./src/**/*.test.ts?(x)"],
},
})
+145
View File
@@ -0,0 +1,145 @@
<p align="center">
<a href="https://thila.dev/effect-view">
<img src="https://github.com/Thiladev/effect-view/raw/master/packages/docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
</a>
</p>
<h1 align="center">Effect View</h1>
<p align="center">
Write React components as typed Effect programs.
</p>
<p align="center">
<a href="https://www.npmjs.com/package/effect-view"><img src="https://img.shields.io/npm/v/effect-view?color=08777b" alt="npm version" /></a>
<a href="https://thila.dev/effect-view"><img src="https://img.shields.io/badge/docs-Effect_View-16a3a1" alt="Effect View documentation" /></a>
<a href="https://github.com/Thiladev/effect-view/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/effect-view?color=e99526" alt="MIT license" /></a>
</p>
<p align="center">
<strong><a href="https://thila.dev/effect-view">Read the documentation →</a></strong>
</p>
Effect View brings Effect's typed services, resource safety, concurrency, and
data modeling into React 19 without replacing React's component model. Yield
Effects and services from a component body, let scopes follow the React
lifecycle, and return ordinary JSX.
> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If
> your application uses Effect v3, use the legacy
> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead.
```bash
npm install effect-view effect@beta react
```
Effect View does not depend on `react-dom`. Install the renderer used by your
application—for example, `react-dom` for the web:
```bash
npm install react-dom
```
You can use Effect View with React Native or any other React renderer instead.
## What it looks like
An Effect View component is an Effect program that produces JSX. It can create
scoped state, access services, and use React-facing hooks without leaving the
generator model:
```tsx
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens } from "effect-view"
import { runtime } from "./runtime"
const CounterValueView = Component.make("CounterValue")(
function* ({ count }: { readonly count: Lens.Lens<number> }) {
yield* Component.useOnMount(() =>
Effect.addFinalizer(() =>
Effect.log("CounterValue unmounted"),
),
)
const [value, setValue] = yield* Lens.useState(count)
return (
<button onClick={() => setValue((n) => n + 1)}>
Count: {value}
</button>
)
},
)
const CounterView = Component.make("Counter")(function* () {
const count = yield* Component.useOnMount(() =>
Effect.map(
SubscriptionRef.make(0),
Lens.fromSubscriptionRef,
),
)
const CounterValue = yield* CounterValueView.use
return <CounterValue count={count} />
})
export const Counter = CounterView.pipe(
Component.withContext(runtime.context),
)
```
`CounterView` composes another Effect View component by yielding its `.use`
Effect, then renders the resulting component as ordinary JSX. The child can
access the current Effect context and receives its own component scope.
`Effect.addFinalizer` uses the scope already provided to the component body, so
the finalizer above runs when `CounterValue` unmounts.
At the outer boundary, `Counter` is still a normal React component and its Effect requirements remain
typed until they reach the runtime.
[Build the runtime and render your first component →](https://thila.dev/effect-view/docs/getting-started)
## Why Effect View
| | Capability | What it gives you |
| --- | --- | --- |
| **Effect components** | `Component.make` | Yield Effects and typed services directly, then return JSX. |
| **Managed lifecycles** | `Scope.Scope` | Finalizers, fibers, subscriptions, and resources close with their component or dependency lifecycle. |
| **One application runtime** | `ReactRuntime` | Build your Layer once and make its services available throughout the UI. |
| **Reactive state** | `Lens` and `View` | Focus large state models into small writable values and subscribe only where React needs them. |
| **Server state** | `Query` and `Mutation` | TanStack Query-style caching, invalidation, staleness, and mutations with typed Effects underneath. |
| **Schema-driven forms** | `MutationForm` and `LensForm` | Keep input-friendly encoded values while application code receives validated, decoded types. |
| **Async rendering** | `Async` and `Memoized` | Integrate asynchronous Effects with Suspense while retaining Effect errors and cancellation. |
## Designed for both ecosystems
Effect View does not hide Effect behind callbacks or recreate React around a
new rendering model. React still owns rendering, JSX, hooks, Suspense, and
events. Effect owns services, errors, resource safety, concurrency, streams,
and schemas. Effect View provides the lifecycle-aware boundary between them.
## Documentation
The complete documentation is available at **[thila.dev/effect-view](https://thila.dev/effect-view)**.
## Requirements
- React 19.2 or newer
- **Effect v4 beta** (`effect@beta`)
- TypeScript and `@types/react` for TypeScript projects
Effect View is renderer-independent and does not require `react-dom`.
## Project status
Effect View is currently beta software. The main APIs are available, but
breaking changes and rough edges are still possible before a stable release.
Issues, ideas, and contributions are welcome on
[GitHub](https://github.com/Thiladev/effect-view).
## License
[MIT](https://github.com/Thiladev/effect-view/blob/main/LICENSE)
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"name": "effect-view",
"description": "Write React function components with Effect",
"version": "0.1.0",
"type": "module",
"files": [
"./README.md",
"./dist"
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-view.git"
},
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./Async": {
"types": "./dist/Async.d.ts",
"default": "./dist/Async.js"
},
"./Component": {
"types": "./dist/Component.d.ts",
"default": "./dist/Component.js"
},
"./Form": {
"types": "./dist/Form.d.ts",
"default": "./dist/Form.js"
},
"./Lens": {
"types": "./dist/Lens.d.ts",
"default": "./dist/Lens.js"
},
"./LensForm": {
"types": "./dist/LensForm.d.ts",
"default": "./dist/LensForm.js"
},
"./Memoized": {
"types": "./dist/Memoized.d.ts",
"default": "./dist/Memoized.js"
},
"./Mutation": {
"types": "./dist/Mutation.d.ts",
"default": "./dist/Mutation.js"
},
"./MutationForm": {
"types": "./dist/MutationForm.d.ts",
"default": "./dist/MutationForm.js"
},
"./PubSub": {
"types": "./dist/PubSub.d.ts",
"default": "./dist/PubSub.js"
},
"./Query": {
"types": "./dist/Query.d.ts",
"default": "./dist/Query.js"
},
"./QueryClient": {
"types": "./dist/QueryClient.d.ts",
"default": "./dist/QueryClient.js"
},
"./ReactRuntime": {
"types": "./dist/ReactRuntime.d.ts",
"default": "./dist/ReactRuntime.js"
},
"./Refreshable": {
"types": "./dist/Refreshable.d.ts",
"default": "./dist/Refreshable.js"
},
"./ScopeRegistry": {
"types": "./dist/ScopeRegistry.d.ts",
"default": "./dist/ScopeRegistry.js"
},
"./SetStateAction": {
"types": "./dist/SetStateAction.d.ts",
"default": "./dist/SetStateAction.js"
},
"./Stream": {
"types": "./dist/Stream.d.ts",
"default": "./dist/Stream.js"
},
"./View": {
"types": "./dist/View.d.ts",
"default": "./dist/View.js"
}
},
"scripts": {
"lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint",
"test": "vitest run",
"build": "tsc -b tsconfig.build.json",
"pack": "npm pack",
"clean:cache": "rm -rf .turbo *.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.101",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.101"
}
}
+63
View File
@@ -0,0 +1,63 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { Effect, Layer } from "effect"
import * as React from "react"
import { describe, expect, it, vi } from "vitest"
import * as Async from "./Async.js"
import * as Component from "./Component.js"
import * as Memoized from "./Memoized.js"
import * as ReactRuntime from "./ReactRuntime.js"
describe("Async", () => {
it("does not rerun for an unrelated parent state update", async () => {
const load = vi.fn((_id: number) => Effect.never)
const renderPost = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const context = await runtime.runtime.context()
const Post = Component.make("Post")(function*(props: { readonly id: number }) {
renderPost()
const value = yield* Component.useOnChange(() => load(props.id), [props.id])
return <div>{value}</div>
}).pipe(
Async.async,
Memoized.memoized,
)
const Parent = Component.make("Parent")(function*() {
const [text, setText] = React.useState("")
const AsyncPost = yield* Post.use
return <>
<input
aria-label="text"
value={text}
onChange={event => setText(event.currentTarget.value)}
/>
<AsyncPost id={1} fallback={<div>loading</div>} />
</>
}).pipe(Component.withContext(runtime.context))
let view!: ReturnType<typeof render>
await act(async () => {
view = render(
<runtime.context.Provider value={context}>
<Parent />
</runtime.context.Provider>,
)
})
expect(screen.getByText("loading")).toBeTruthy()
expect(load).toHaveBeenCalledTimes(2)
const callsAfterLoad = load.mock.calls.length
const rendersAfterLoad = renderPost.mock.calls.length
await act(async () => {
fireEvent.change(screen.getByLabelText("text"), { target: { value: "a" } })
})
expect(load).toHaveBeenCalledTimes(callsAfterLoad)
expect(renderPost).toHaveBeenCalledTimes(rendersAfterLoad)
view.unmount()
await runtime.runtime.dispose()
})
})

Some files were not shown because too many files have changed in this diff Show More