122 Commits

Author SHA1 Message Date
Julien Valverdé d32389fa81 Fix CI
Lint / lint (push) Successful in 55s
2026-07-26 00:13:35 +02:00
Julien Valverdé 5e9c25641f Fix
Lint / lint (push) Failing after 39s
2026-07-25 23:56:44 +02:00
Julien Valverdé 2b677f8810 Fix tests
Lint / lint (push) Failing after 39s
2026-07-25 23:54:24 +02:00
Julien Valverdé 73399c1b24 Fix tests
Lint / lint (push) Failing after 39s
2026-07-25 23:52:11 +02:00
Julien Valverdé 65e90325d0 Change build system
Lint / lint (push) Failing after 40s
2026-07-25 23:47:16 +02:00
Julien Valverdé 3533fa0c84 Remove ErrorObserver
Lint / lint (push) Failing after 39s
2026-07-25 22:48:42 +02:00
Julien Valverdé c40df980ed Rename vite plugin
Lint / lint (push) Failing after 38s
2026-07-25 22:44:09 +02:00
Julien Valverdé 23547afe91 Fix refresh plugin
Lint / lint (push) Failing after 39s
2026-07-25 22:27:11 +02:00
Julien Valverdé 462895a3c2 Add Refreshable trait
Lint / lint (push) Failing after 1m45s
2026-07-25 02:02:17 +02:00
Julien Valverdé 116c1b440d Add tests to CI
Lint / lint (push) Failing after 43s
2026-07-24 23:42:22 +02:00
Julien Valverdé 7d6bab81be Fix ScopeRegistry
Lint / lint (push) Failing after 41s
2026-07-24 23:07:06 +02:00
Julien Valverdé faac021527 Fix Async
Lint / lint (push) Failing after 42s
2026-07-23 04:58:41 +02:00
Julien Valverdé 3a25b1a34e Fix example
Lint / lint (push) Failing after 41s
2026-07-23 04:36:31 +02:00
Julien Valverdé 296f65ee1b Fix
Lint / lint (push) Failing after 42s
2026-07-23 04:13:27 +02:00
Julien Valverdé d8b9c0394c Fix
Lint / lint (push) Failing after 48s
2026-07-23 03:35:38 +02:00
Julien Valverdé 71f66cd5bc Fix
Lint / lint (push) Failing after 49s
2026-07-23 03:33:15 +02:00
Julien Valverdé d09c17e1c2 Fix
Lint / lint (push) Failing after 48s
2026-07-23 03:26:14 +02:00
Julien Valverdé cce0a47ea5 Fix
Lint / lint (push) Failing after 51s
2026-07-23 03:22:16 +02:00
Julien Valverdé 7e816d041e Fix
Lint / lint (push) Failing after 48s
2026-07-23 03:13:17 +02:00
Julien Valverdé c6eeb7b8bc Fix
Lint / lint (push) Failing after 2m8s
2026-07-23 03:08:42 +02:00
Julien Valverdé ccb33973db Fix
Lint / lint (push) Failing after 41s
2026-07-23 01:34:17 +02:00
Julien Valverdé 3516ba05f8 Fix
Lint / lint (push) Failing after 42s
2026-07-23 01:30:29 +02:00
Julien Valverdé cb62639829 Implement ScopeRegistry
Lint / lint (push) Failing after 42s
2026-07-23 01:22:14 +02:00
Julien Valverdé 8fed17789a ScopeMap -> ScopeRegistry
Lint / lint (push) Failing after 43s
2026-07-22 22:51:38 +02:00
Julien Valverdé c151b4608f Add report
Lint / lint (push) Failing after 41s
2026-07-22 22:22:25 +02:00
Julien Valverdé cce1d6e485 Fix
Lint / lint (push) Failing after 42s
2026-07-22 21:41:26 +02:00
Julien Valverdé a5867ef36a Fix Query tests
Lint / lint (push) Failing after 42s
2026-07-22 21:07:16 +02:00
Julien Valverdé fa058ecf53 Improve README
Lint / lint (push) Failing after 43s
2026-07-22 20:56:51 +02:00
Julien Valverdé ffed4bda40 Improve README 2026-07-22 20:56:42 +02:00
Julien Valverdé c48ca0acd3 Add ErrorHandler report
Lint / lint (push) Failing after 2m32s
2026-07-22 06:04:37 +02:00
Julien Valverdé 6e7172a24e Add React Refresh feasibility report
Lint / lint (push) Failing after 42s
2026-07-22 05:11:45 +02:00
Julien Valverdé b91729888c Fix
Lint / lint (push) Failing after 41s
2026-07-22 04:56:32 +02:00
Julien Valverdé 5f3e560969 Fix docs
Lint / lint (push) Failing after 41s
2026-07-22 04:52:40 +02:00
Julien Valverdé 8f8a66f7a9 Add logo
Lint / lint (push) Failing after 41s
2026-07-22 04:51:05 +02:00
Julien Valverdé 9789969ecf Improve docs landing page
Lint / lint (push) Failing after 44s
2026-07-22 04:24:59 +02:00
Julien Valverdé 4be5b12121 Update docs
Lint / lint (push) Failing after 43s
2026-07-22 04:13:14 +02:00
Julien Valverdé 547aeec2fb Update docs
Lint / lint (push) Failing after 41s
2026-07-22 03:51:26 +02:00
Julien Valverdé 1651e96696 Update docs
Lint / lint (push) Failing after 44s
2026-07-22 03:44:16 +02:00
Julien Valverdé 22933b2a09 Update docs
Lint / lint (push) Failing after 41s
2026-07-22 03:34:27 +02:00
Julien Valverdé 36c47e1b4b Improve docs
Lint / lint (push) Failing after 42s
2026-07-22 03:23:16 +02:00
Julien Valverdé c4b954d21c Update docs
Lint / lint (push) Failing after 42s
2026-07-22 02:51:43 +02:00
Julien Valverdé f669dbee3a Add Mutation docs
Lint / lint (push) Failing after 42s
2026-07-22 02:21:36 +02:00
Julien Valverdé e610fa1773 Add Query docs
Lint / lint (push) Failing after 1m44s
2026-07-22 02:09:57 +02:00
Julien Valverdé 75f51cdf1c Overhaul form docs
Lint / lint (push) Failing after 42s
2026-07-22 01:31:17 +02:00
Julien Valverdé d7f0aa1a38 Fix docs
Lint / lint (push) Failing after 42s
2026-07-22 01:17:11 +02:00
Julien Valverdé 9a93d72e48 Change useContextFromLayer to useLayer
Lint / lint (push) Failing after 43s
2026-07-20 20:40:13 +02:00
Julien Valverdé 19d5aba009 Docs work
Lint / lint (push) Failing after 47s
2026-07-20 07:35:44 +02:00
Julien Valverdé 5017ccf5ba Docs work
Lint / lint (push) Failing after 46s
2026-07-20 07:26:17 +02:00
Julien Valverdé 10784322b1 Docs work
Lint / lint (push) Failing after 46s
2026-07-20 07:24:17 +02:00
Julien Valverdé 1fa2440d71 Fix
Lint / lint (push) Failing after 49s
2026-07-20 07:20:26 +02:00
Julien Valverdé fb4fd633dc Fix
Lint / lint (push) Failing after 40s
2026-07-20 03:14:40 +02:00
Julien Valverdé c804262e5f Fix
Lint / lint (push) Failing after 40s
2026-07-20 03:10:40 +02:00
Julien Valverdé a47ae73618 Add a working LensForm example
Lint / lint (push) Failing after 40s
2026-07-20 03:10:27 +02:00
Julien Valverdé efdf490bc0 Fix form example
Lint / lint (push) Failing after 39s
2026-07-20 02:43:38 +02:00
Julien Valverdé 8bbeed9b8d Fix scope registry race condition
Lint / lint (push) Successful in 1m43s
2026-07-20 02:15:39 +02:00
Julien Valverdé 4ba7943bef Test 2026-07-20 01:59:29 +02:00
Julien Valverdé 89c8ac59bc Fix
Lint / lint (push) Successful in 39s
2026-07-20 01:33:45 +02:00
Julien Valverdé d21ff53c26 Fix Query state
Lint / lint (push) Successful in 39s
2026-07-20 00:57:25 +02:00
Julien Valverdé 6551969bb1 Fix Query lifecycle
Lint / lint (push) Successful in 39s
2026-07-19 20:41:59 +02:00
Julien Valverdé cc01e46997 Fix runtime
Lint / lint (push) Successful in 38s
2026-07-19 18:55:01 +02:00
Julien Valverdé 1dc8368750 Rename withRuntime to withContext
Lint / lint (push) Successful in 39s
2026-07-19 18:44:01 +02:00
Julien Valverdé f22743eefe Fix
Lint / lint (push) Successful in 39s
2026-07-19 18:20:22 +02:00
Julien Valverdé 31d87ffcab Fix
Lint / lint (push) Successful in 38s
2026-07-19 04:00:46 +02:00
Julien Valverdé 885cff74b3 Example fix
Lint / lint (push) Successful in 38s
2026-07-19 03:27:41 +02:00
Julien Valverdé cbb050673b Fix
Lint / lint (push) Successful in 38s
2026-07-19 03:20:24 +02:00
Julien Valverdé 9b25ef4b57 Fix
Lint / lint (push) Successful in 39s
2026-07-19 03:15:55 +02:00
Julien Valverdé be1038fc8d Rename Form subtypes
Lint / lint (push) Failing after 40s
2026-07-19 03:01:38 +02:00
Julien Valverdé 956c77d45b Fix
Lint / lint (push) Failing after 38s
2026-07-19 02:56:32 +02:00
Julien Valverdé 985556b430 Fix
Lint / lint (push) Failing after 39s
2026-07-19 02:50:37 +02:00
Julien Valverdé feced4732f Refactor Form subtypes
Lint / lint (push) Failing after 1m43s
2026-07-19 02:43:48 +02:00
Julien Valverdé cc64ae785d Refactor Form
Lint / lint (push) Failing after 39s
2026-07-19 00:46:36 +02:00
Julien Valverdé 3fc3904c6c Upgrade effect-lens
Lint / lint (push) Failing after 1m44s
2026-07-17 21:25:07 +02:00
Julien Valverdé 23cdfb3894 Refactor Query
Lint / lint (push) Failing after 37s
2026-07-17 01:15:53 +02:00
Julien Valverdé 7a7783e7de Refactor Query
Lint / lint (push) Failing after 45s
2026-07-07 21:22:05 +02:00
Julien Valverdé e07ce1cd68 Refactor Query
Lint / lint (push) Failing after 1m1s
2026-07-01 21:12:05 +02:00
Julien Valverdé c59e2ee035 Refactor Query/Mutation
Lint / lint (push) Failing after 14s
2026-06-26 02:54:40 +02:00
Julien Valverdé e5ac120cea Refactor Mutation
Lint / lint (push) Failing after 48s
2026-06-26 02:38:24 +02:00
Julien Valverdé 015aa0c816 Refactor Query
Lint / lint (push) Failing after 49s
2026-06-25 04:37:14 +02:00
Julien Valverdé 5945953555 Refactor QueryClient
Lint / lint (push) Failing after 16s
2026-06-24 00:54:10 +02:00
Julien Valverdé 65fcfb395e Refactor
Lint / lint (push) Successful in 15s
2026-06-23 04:08:49 +02:00
Julien Valverdé a86cb7e024 Refactor Component
Lint / lint (push) Failing after 16s
2026-06-23 03:57:56 +02:00
Julien Valverdé 24ce543092 Fix
Lint / lint (push) Failing after 14s
2026-06-23 03:32:13 +02:00
Julien Valverdé 6b5c501a98 Refactor
Lint / lint (push) Failing after 1m6s
2026-06-23 03:25:58 +02:00
Julien Valverdé c8df30480a Fix
Lint / lint (push) Failing after 16s
2026-06-22 06:44:31 +02:00
Julien Valverdé a38be0b172 Component v4 migration work
Lint / lint (push) Failing after 16s
2026-06-22 06:36:32 +02:00
Julien Valverdé 091e102b23 Add Effect v4 version
Lint / lint (push) Successful in 48s
2026-06-22 02:04:20 +02:00
Julien Valverdé b7ea35006d Add forms doc
Lint / lint (push) Successful in 16s
2026-06-22 01:38:06 +02:00
Julien Valverdé 257d505fdf Docs
Lint / lint (push) Successful in 14s
2026-06-12 01:51:30 +02:00
Julien Valverdé 241d489b0c Docs
Lint / lint (push) Successful in 14s
2026-06-12 01:44:40 +02:00
Julien Valverdé cf2cb8bf09 Docs
Lint / lint (push) Successful in 16s
2026-06-11 14:25:00 +02:00
Julien Valverdé 7ae5d08555 Docs
Lint / lint (push) Successful in 46s
2026-06-11 12:29:10 +02:00
Julien Valverdé 9fb56da120 Docs
Lint / lint (push) Successful in 35s
2026-06-09 22:25:53 +02:00
Julien Valverdé 1cead4bb7b Docs
Lint / lint (push) Successful in 16s
2026-06-09 22:21:26 +02:00
Julien Valverdé ad1ef4a73b Docs
Lint / lint (push) Successful in 16s
2026-06-09 22:15:41 +02:00
Julien Valverdé 81fb1dcf42 Docs
Lint / lint (push) Successful in 16s
2026-06-09 21:59:12 +02:00
Julien Valverdé c90c5f9532 Docs
Lint / lint (push) Successful in 15s
2026-06-09 21:44:08 +02:00
Julien Valverdé 436dc275b3 Docs
Lint / lint (push) Successful in 16s
2026-06-09 20:34:24 +02:00
Julien Valverdé 5791c08b51 Docs
Lint / lint (push) Successful in 18s
2026-06-09 19:00:09 +02:00
Julien Valverdé a74ec6e398 Docs
Lint / lint (push) Successful in 17s
2026-06-09 18:09:47 +02:00
Julien Valverdé fe65cd96aa Docs
Lint / lint (push) Successful in 16s
2026-06-09 15:30:00 +02:00
Julien Valverdé c492408969 Docs
Lint / lint (push) Successful in 14s
2026-06-09 14:50:25 +02:00
Julien Valverdé 86bdb5374e Docs
Lint / lint (push) Successful in 15s
2026-06-09 13:35:58 +02:00
Julien Valverdé 054f8773f0 Docs
Lint / lint (push) Successful in 16s
2026-06-09 11:34:13 +02:00
Julien Valverdé eb81ff16d2 Docs
Lint / lint (push) Successful in 15s
2026-06-09 11:27:39 +02:00
Julien Valverdé 3000ff2d87 Docs
Lint / lint (push) Successful in 17s
2026-06-09 11:18:08 +02:00
Julien Valverdé 91670779fe Docs
Lint / lint (push) Successful in 15s
2026-06-09 11:16:33 +02:00
Julien Valverdé d3525aaad7 Docs
Lint / lint (push) Successful in 16s
2026-06-09 11:09:47 +02:00
Julien Valverdé 0a59fdf2b4 Docs
Lint / lint (push) Successful in 17s
2026-06-09 10:54:00 +02:00
Julien Valverdé 88b3a1c98a Docs
Lint / lint (push) Successful in 19s
2026-06-09 10:48:46 +02:00
Julien Valverdé 74a8e3a102 Fix
Lint / lint (push) Successful in 47s
2026-06-09 10:42:42 +02:00
Julien Valverdé 8303c1f70e Docs
Lint / lint (push) Successful in 16s
2026-06-08 22:23:49 +02:00
Julien Valverdé c941e5970a Docs
Lint / lint (push) Successful in 16s
2026-06-08 22:19:58 +02:00
Julien Valverdé a8d4520fed Docs
Lint / lint (push) Successful in 16s
2026-06-08 22:03:42 +02:00
Julien Valverdé d40ac326ec Docs
Lint / lint (push) Successful in 15s
2026-06-08 22:01:58 +02:00
Julien Valverdé 05a8ae9ae4 Docs
Lint / lint (push) Successful in 14s
2026-06-08 21:47:27 +02:00
Julien Valverdé 25fac0ca32 Fix
Lint / lint (push) Successful in 15s
2026-06-08 15:35:15 +02:00
Julien Valverdé 845aa193ba Add getting started docs
Lint / lint (push) Successful in 16s
2026-06-08 13:55:16 +02:00
Julien Valverdé 1292b9885a Fix
Lint / lint (push) Successful in 48s
2026-06-08 11:14:31 +02:00
Julien Valverdé 658e6bb8ea Fix tests
Lint / lint (push) Successful in 13s
2026-06-07 15:15:57 +02:00
Julien Valverdé 7638324f2f Add tests
Lint / lint (push) Successful in 14s
2026-06-07 15:10:26 +02:00
Julien Valverdé fcb29c0d76 Add tests
Lint / lint (push) Successful in 15s
2026-06-07 15:01:07 +02:00
Julien Valverdé ea0108650b Add tests
Lint / lint (push) Successful in 47s
2026-06-07 14:51:18 +02:00
122 changed files with 1346 additions and 1799 deletions
-46
View File
@@ -11,35 +11,16 @@ 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:
@@ -47,30 +28,3 @@ 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,48 +13,15 @@ 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
@@ -1,12 +0,0 @@
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
@@ -1,10 +1,10 @@
# Effect View error-handling review
# Effect FC Next error-handling review
## Verdict
`ErrorObserver` is a reasonable *opt-in failure notification bus*, but it is
not an application error handler. It neither recovers from errors nor, in
`effect-view`, observes the application's effects by default. Keeping it
`effect-fc-next`, observes the application's effects by default. Keeping it
as the central error-handling mechanism would make error delivery implicit,
incomplete, and easy to duplicate.
@@ -20,7 +20,7 @@ should not replace that state.
## What exists today
`packages/effect-view/src/ErrorObserver.ts` provides a context service
`packages/effect-fc-next/src/ErrorObserver.ts` provides a context service
backed by an unbounded `PubSub<Cause.Cause<E>>`:
- `handle(effect)` uses `Effect.tapCause` to publish every non-successful
@@ -33,7 +33,7 @@ This is narrower than the similarly named service in the legacy
Effect supervisor that attempts to publish every failed fiber. The Next
runtime's `preludeLayer` contains only `Component.ScopeMap`; it does not
install `ErrorObserver.layer`. There are also no `ErrorObserver.handle(...)`
call sites in `effect-view`.
call sites in `effect-fc-next`.
Therefore, as checked at this revision, a consumer can create and subscribe to
the service manually, but no failure is automatically delivered to it. It is
@@ -2,13 +2,13 @@
## Purpose
This report describes the lifecycle problem in `effect-view`, why abandoned React renders leak Effect scopes, what the experimental implementations attempted, why those implementations are not satisfactory, and how a robust system should be designed.
This report describes the lifecycle problem in `effect-fc-next`, why abandoned React renders leak Effect scopes, what the experimental implementations attempted, why those implementations are not satisfactory, and how a robust system should be designed.
The experimental implementation discussed here has been removed from the source tree. This document is intended as design material for a clean reimplementation.
## Executive summary
The fundamental problem is not scope disposal by itself. It is that `effect-view` performs Effect work and creates resource scopes while React is rendering.
The fundamental problem is not scope disposal by itself. It is that `effect-fc-next` performs Effect work and creates resource scopes while React is rendering.
React distinguishes between two broad phases:
@@ -467,7 +467,7 @@ interface ComponentScope {
Create it at commit and close it at effect cleanup. No registry, snapshot, subscription, speculative timer, or abandonment recovery is necessary unless runtime-wide disposal must independently own it.
## Final recommendation for `effect-view`
## Final recommendation for `effect-fc-next`
Do not attempt to hide both committed component lifecycles and render-time Suspense resources behind one implicit `Scope` mechanism.
@@ -26,12 +26,13 @@ A production-quality Vite implementation is likely **1018 engineer-days**, in
## Scope examined
The implementation target is `effect-view` (Effect 4 beta). The legacy
`effect-fc` package is not supported by the Vite plugin.
The implementation target is `effect-fc-next` (Effect 4 beta), which is
expected to become `effect-view`. The legacy `effect-fc` package is not
supported by the Vite plugin.
## How Effect View rendering works today
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-view/src/Component.ts`](packages/effect-view/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
`Component.make` creates a function-shaped descriptor with a `body` property and `ComponentPrototype`; it does not create the React component that will be mounted. See [`packages/effect-fc-next/src/Component.ts`](packages/effect-fc-next/src/Component.ts), around `make`, `makeUntraced`, and `ComponentImplPrototype`.
The relevant path is:
@@ -44,7 +45,7 @@ source definition
-> mounted React fiber
```
In `effect-view`, `asFunctionComponent` creates a closure over the descriptor
In `effect-fc-next`, `asFunctionComponent` creates a closure over the descriptor
and Effect context. `.use` retains that synthesized function in `componentRef`
while the relevant Effect service identities remain unchanged, and
`withContext` renders the function returned by `.use`.
@@ -87,7 +88,7 @@ Consequently, edits currently propagate through ordinary Vite HMR invalidation.
The initial feasibility study used the legacy implementation for a focused
jsdom/React identity experiment. Its descriptor-caching result also applies to
`effect-view`, but the legacy package is not an implementation target:
`effect-fc-next`, but the legacy package is not an implementation target:
1. Render an Effect View containing `React.useState(0)`.
2. Increment it to `old:1`.
@@ -133,7 +134,7 @@ Ship a Vite plugin, `@effect-view/vite-plugin`, placed before `react()`:
```ts
plugins: [
effectView(),
effectViewPlugin(),
react(),
]
```
@@ -144,7 +145,7 @@ It should recognize:
- `Component.makeUntraced(...)`
- class declarations extending either factory result
- definitions followed by Effect `pipe` transformations
- aliased imports from `effect-view`
- aliased imports from `effect-fc-next`
For each definition it should inject development-only metadata containing:
@@ -254,7 +255,7 @@ Exit criterion: editing render text updates without a page reload, and incompati
### Phase 1: Vite MVP (47 additional days)
- Implement binding-aware AST detection for all public construction styles.
- Add stable shell/keyed implementation support to the supported `effect-view` Component implementation.
- Add stable shell/keyed implementation support to the supported `effect-fc-next` Component implementation.
- Cover memoized and async traits.
- Add safe self-accept/fallback behavior.
- Add browser integration tests against the example app.
@@ -294,7 +295,7 @@ Minimum cases:
- Async View and Suspense/error recovery.
- Two materializations of the same View under different Effect contexts.
- Reactive and `nonReactiveTags` service changes.
- TanStack Router code splitting used by the `example` application.
- TanStack Router code splitting used by the `example-next` application.
- Successive edits before the previous refresh finishes.
- Syntax error followed by recovery.
- Production build contains no HMR registry/subscription code.
+5 -59
View File
@@ -1,61 +1,7 @@
<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 FC Monorepo
<h1 align="center">Effect View Monorepo</h1>
[Effect-TS](https://effect.website/) integration for React 19.2+ that allows you to write function components using Effect generators.
<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)
This monorepo contains:
- [The `effect-fc` library](packages/effect-fc)
- [An example project](packages/example)
+269 -297
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,5 +1,5 @@
{
"name": "@effect-view/monorepo",
"name": "@effect-fc/monorepo",
"packageManager": "bun@1.3.14",
"private": true,
"workspaces": [
@@ -16,12 +16,12 @@
"clean:modules": "turbo clean:modules && rm -rf node_modules"
},
"devDependencies": {
"@biomejs/biome": "^2.5.5",
"@effect/language-service": "^0.87.1",
"@biomejs/biome": "^2.4.16",
"@effect/language-service": "^0.86.2",
"@types/bun": "^1.3.14",
"npm-check-updates": "^23.0.0",
"npm-check-updates": "^22.2.1",
"npm-sort": "^0.0.4",
"turbo": "^2.10.7",
"typescript": "^7.0.2"
"turbo": "^2.9.16",
"typescript": "^6.0.3"
}
}
-10
View File
@@ -4,15 +4,5 @@
"extends": "//",
"files": {
"includes": ["./src/**"]
},
"linter": {
"rules": {
"complexity": {
"noImportantStyles": "off"
},
"style": {
"noDescendingSpecificity": "off"
}
}
}
}
-145
View File
@@ -1,145 +0,0 @@
---
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.
+121 -139
View File
@@ -52,14 +52,6 @@ valid decoded value should go:
| `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
@@ -110,9 +102,9 @@ 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.
`MutationForm.service` also starts initial validation in the current scope. Use
it inside `Component.useOnMount`, a scoped service, or another Effect scope so
its validation and mutation work is cleaned up with its owner.
## LensForm: validate, then synchronize
@@ -171,6 +163,124 @@ 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.
## 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.
## Focus into subforms
The root `MutationForm` or `LensForm` represents the complete schema. UI
@@ -190,16 +300,6 @@ 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`
@@ -355,121 +455,3 @@ They expose the schema pipeline as reactive Lenses and Views:
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.
+51 -68
View File
@@ -35,33 +35,6 @@ 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
@@ -199,25 +172,51 @@ 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.
A regular Effect View component runs its body during React render. That Effect
must complete synchronously: yielding a service, reading synchronous state, or
creating a scoped object is fine; sleeping, fetching, or awaiting a promise is
not.
Choose the integration that matches the asynchronous work:
Use `Async.async` when render genuinely depends on an asynchronous Effect. The
component then suspends and accepts React Suspense props such as `fallback`:
- 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.
```tsx title="src/UserView.tsx"
import { Async, Component, Memoized } from "effect-view"
import { loadUser } from "./api"
export const UserView = Component.make("UserView")(
function* (props: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(props.userId),
[props.userId],
)
return <h2>{user.name}</h2>
},
).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
Memoized.memoized,
)
```
Use it from an Effect View parent in the usual way:
```tsx
const User = yield* UserView.use
return <User userId={selectedUserId} />
```
`Memoized.memoized` prevents an unrelated parent re-render from restarting an
async child whose props have not changed. A changed `userId` creates a new
dependency scope, cleans up the previous one, and runs `loadUser` again.
An async component's rejected Effect is handled by the nearest React error
boundary. Suspense handles waiting, not failures.
For server data that should be cached, refreshed, and shared, prefer the
[Query module](./query) over a raw async component.
## Use Effect services
@@ -295,25 +294,24 @@ const ResourceView = Component.make("Resource")(function* () {
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.
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each create a scope
that can be replaced without closing the component root scope.
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 |
| `useOnMount` | Computes and caches a value for the component instance | The same 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 |
| `useLayer` | Builds and provides a layer context | A dependency scope created through `useOnChange` | 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.
`useRunSync`, `useRunPromise`, `useCallbackSync`, and `useCallbackPromise` do
not create lifecycle scopes either. They capture the component context, so an
Effect invoked through them sees the component root scope unless it explicitly
provides another one.
### useOnMount
@@ -411,21 +409,6 @@ 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:
-5
View File
@@ -30,11 +30,6 @@ 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"
+8 -23
View File
@@ -35,6 +35,7 @@ application runtime once, alongside the services used by your query effects:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
@@ -43,6 +44,7 @@ const AppLive = Layer.empty.pipe(
defaultRefreshOnWindowFocus: true,
cacheGcTime: "5 minutes",
})),
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
@@ -50,9 +52,7 @@ 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).
client defaults.
## Create a reactive query
@@ -60,12 +60,6 @@ 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"
@@ -124,9 +118,9 @@ 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.
`Query.service` creates the query and starts watching its key in the current
scope. Creating it in `Component.useOnMount` keeps one query instanceand one
stable query function identityfor the component's lifetime.
## Render AsyncResult
@@ -270,17 +264,8 @@ const query = yield* Query.service({
})
```
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.
Window focus resolves the current key again, subject to the same freshness
check. The browser integration is ignored in non-browser environments.
## The Effect touch
+16 -39
View File
@@ -12,9 +12,9 @@ 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
[`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://www.npmjs.com/package/effect-lens/v/beta) for the full Lens/View API.
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) 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
@@ -219,16 +219,15 @@ 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> }
{
readonly profile: Lens.Lens<UserProfile>
readonly name: Lens.Lens<string>
readonly role: Lens.Lens<string>
}
>()("ProfileState") {
static readonly layer = Layer.effect(
ProfileState,
@@ -238,35 +237,20 @@ class ProfileState extends Context.Service<
name: "",
email: "",
role: "reader",
contact: {
address: {
city: "",
},
},
}),
)
const name = Lens.focusObjectOn(profile, "name")
const role = Lens.focusObjectOn(profile, "role")
return { profile } as const
return { profile, name, role } 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])
const [name, setName] = yield* Lens.useState(state.name)
const [role] = yield* View.useAll([state.role])
return (
<label>
@@ -276,22 +260,15 @@ const ProfileNameView = Component.make("ProfileName")(function*() {
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.
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
`View.useAll` path.
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).
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
+4 -9
View File
@@ -46,11 +46,11 @@ const config: Config = {
lastVersion: "current",
versions: {
current: {
label: "effect-view v0",
label: "effect-view v1",
},
"effect-fc-v0": {
label: "effect-fc v0",
path: "effect-fc/v0",
"effect-fc-1.0": {
label: "effect-fc 1.0",
path: "effect-fc/1.0",
},
},
},
@@ -84,11 +84,6 @@ const config: Config = {
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",
+9 -13
View File
@@ -3,25 +3,21 @@
"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",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve --dir dist",
"serve": "docusaurus serve",
"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"
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "^3.10.2",
"@docusaurus/faster": "^3.10.2",
"@docusaurus/preset-classic": "^3.10.2",
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
@@ -29,9 +25,9 @@
"react-dom": "^19.2.5"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"typescript": "~6.0.3"
-1
View File
@@ -6,7 +6,6 @@ const sidebars: SidebarsConfig = {
docsSidebar: [
"getting-started",
"state-management",
"async",
"query",
"mutation",
"forms",
+1 -27
View File
@@ -72,15 +72,9 @@
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 {
.hero h1 span {
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
background-clip: text;
color: transparent;
@@ -163,26 +157,6 @@
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;
+2 -9
View File
@@ -100,8 +100,8 @@ export default function Home(): ReactNode {
Effect View for React 19
</div>
<h1>
React <span className={styles.unbreakable}>components,</span>
<span className={styles.heroAccent}> powered by Effect.</span>
React components,
<span> powered by Effect.</span>
</h1>
<p className={styles.lede}>
Bring typed services, scoped resources, reactive state, server
@@ -126,13 +126,6 @@ export default function Home(): ReactNode {
<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}>
+1 -1
View File
@@ -1,3 +1,3 @@
[
"effect-fc-v0"
"effect-fc-1.0"
]
-51
View File
@@ -1,51 +0,0 @@
# 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,
},
})
```
@@ -1,143 +0,0 @@
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
})
@@ -1,24 +0,0 @@
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
})
@@ -1,117 +0,0 @@
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)
})
@@ -1,6 +1,6 @@
<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" />
<img src="../docs/static/img/logo.svg" width="104" height="104" alt="Effect View logo" />
</a>
</p>
@@ -136,6 +136,9 @@ Effect View is renderer-independent and does not require `react-dom`.
Effect View is currently beta software. The main APIs are available, but
breaking changes and rough edges are still possible before a stable release.
React Fast Refresh does not yet recognize Effect View component definitions, so
changes to them currently require a page reload; ordinary React components are
unaffected (currently being worked on).
Issues, ideas, and contributions are welcome on
[GitHub](https://github.com/Thiladev/effect-view).
@@ -1,7 +1,7 @@
{
"name": "effect-view",
"name": "effect-fc-next",
"description": "Write React function components with Effect",
"version": "0.1.0",
"version": "0.1.0-beta.0",
"type": "module",
"files": [
"./README.md",
@@ -9,7 +9,7 @@
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-view.git"
"url": "git+https://github.com/Thiladev/effect-fc.git"
},
"types": "./dist/index.d.ts",
"exports": {
@@ -97,19 +97,19 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "4.0.0-beta.101",
"@effect/platform-browser": "4.0.0-beta.98",
"@testing-library/react": "^16.3.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.98",
"jsdom": "^26.1.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@types/react": "^19.2.0",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.98",
"react": "^19.2.0"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"effect-lens": "^2.0.1-beta.101"
"effect-lens": "^2.0.0-beta.1"
}
}
@@ -4,7 +4,7 @@ import * as React from "react"
import * as Component from "./Component.js"
export const AsyncTypeId: unique symbol = Symbol.for("@effect-view/Async/Async")
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type AsyncTypeId = typeof AsyncTypeId
@@ -277,7 +277,6 @@ describe("Component", () => {
)
await screen.findByText("first")
vi.useFakeTimers()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -285,19 +284,16 @@ describe("Component", () => {
</runtime.context.Provider>
)
expect(screen.getByText("second")).toBeTruthy()
await screen.findByText("second")
expect(cleanup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(19)
await new Promise(resolve => setTimeout(resolve, 5))
expect(cleanup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(cleanup).toHaveBeenCalledWith("first")
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
view.unmount()
await vi.advanceTimersByTimeAsync(20)
expect(cleanup).toHaveBeenCalledWith("second")
vi.useRealTimers()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
await runtime.runtime.dispose()
})
@@ -5,7 +5,7 @@ import * as React from "react"
import * as ScopeRegistry from "./ScopeRegistry.js"
export const ComponentTypeId: unique symbol = Symbol.for("@effect-view/Component/Component")
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type ComponentTypeId = typeof ComponentTypeId
/**
@@ -416,9 +416,9 @@ export declare namespace make {
}
/**
* Creates an Effect View Component using the same overloads and pipeline composition style as `Effect.fn`.
* Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`.
*
* This is the **recommended** approach for defining Effect View components. It provides comprehensive
* This is the **recommended** approach for defining Effect-FC components. It provides comprehensive
* support for multiple component definition patterns:
*
* - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations
@@ -491,7 +491,7 @@ export const make: (
}
/**
* Creates an Effect View Component without automatic distributed tracing.
* Creates an Effect-FC Component without automatic distributed tracing.
*
* This function provides the same API surface as `make`, but does not create automatic tracing spans.
* It follows the exact same overload structure as `Effect.fnUntraced`.
@@ -608,19 +608,19 @@ export const withOptions: {
))
/**
* Wraps an Effect View Component and converts it into a standard React function component,
* serving as an **entrypoint** into an Effect View component hierarchy.
* Wraps an Effect-FC Component and converts it into a standard React function component,
* serving as an **entrypoint** into an Effect-FC component hierarchy.
*
* This is how Effect View components are integrated with the broader React ecosystem,
* This is how Effect-FC components are integrated with the broader React ecosystem,
* particularly when:
* - Using client-side routers (TanStack Router, React Router, etc.)
* - Implementing lazy-loaded or code-split routes
* - Connecting to third-party libraries expecting standard React components
* - Creating component boundaries between Effect View and non-Effect View code
* - Creating component boundaries between Effect-FC and non-Effect-FC code
*
* The Effect runtime is obtained from the provided React Context.
*
* @param self - The Effect View Component to be rendered as a standard React component
* @param self - The Effect-FC Component to be rendered as a standard React component
* @param context - React Context providing the Effect Runtime for this component tree.
* Create this using the `ReactRuntime` module.
*
@@ -6,7 +6,7 @@ import * as Lens from "./Lens.js"
import * as View from "./View.js"
export const FormTypeId: unique symbol = Symbol.for("@effect-view/Form/Form")
export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form")
export type FormTypeId = typeof FormTypeId
export interface Form<out P extends readonly PropertyKey[], out A, in out I = A, out ER = never, out EW = never>
@@ -5,7 +5,7 @@ import * as Lens from "./Lens.js"
import * as View from "./View.js"
export const LensFormTypeId: unique symbol = Symbol.for("@effect-view/Form/LensForm")
export const LensFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/LensForm")
export type LensFormTypeId = typeof LensFormTypeId
export interface LensForm<in out A, in out I = A, in out RD = never, in out RE = never, out TER = never, out TEW = never, in out TRR = never, in out TRW = never>
@@ -4,7 +4,7 @@ import * as React from "react"
import type * as Component from "./Component.js"
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-view/Memoized/Memoized")
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type MemoizedTypeId = typeof MemoizedTypeId
@@ -4,7 +4,7 @@ import * as Lens from "./Lens.js"
import * as View from "./View.js"
export const MutationTypeId: unique symbol = Symbol.for("@effect-view/Mutation/Mutation")
export const MutationTypeId: unique symbol = Symbol.for("@effect-fc/Mutation/Mutation")
export type MutationTypeId = typeof MutationTypeId
export interface Mutation<in out K, out A, out E = never, in out R = never>
@@ -7,7 +7,7 @@ import * as Mutation from "./Mutation.js"
import * as View from "./View.js"
export const MutationFormTypeId: unique symbol = Symbol.for("@effect-view/Form/MutationForm")
export const MutationFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/MutationForm")
export type MutationFormTypeId = typeof MutationFormTypeId
export interface MutationForm<in out A, in out I = A, in out RD = never, in out RE = never, out MA = void, out ME = never, in out MR = never>
@@ -5,7 +5,7 @@ import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-view/Query/Query")
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K, out A, out E = never, in out R = never>
@@ -4,7 +4,7 @@ import * as Lens from "./Lens.js"
import type * as View from "./View.js"
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientService")
export const QueryClientServiceTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientService")
export type QueryClientServiceTypeId = typeof QueryClientServiceTypeId
export interface QueryClientService extends Pipeable.Pipeable {
@@ -27,7 +27,7 @@ export interface QueryClientService extends Pipeable.Pipeable {
}
export class QueryClient extends Context.Service<QueryClient, QueryClientService>()(
"@effect-view/QueryClient/QueryClient"
"@effect-fc/QueryClient/QueryClient"
) {}
export class QueryClientServiceImpl
@@ -129,7 +129,7 @@ export const service = (
export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options))
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey")
export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheKey")
export type QueryClientCacheKeyTypeId = typeof QueryClientCacheKeyTypeId
export class QueryClientCacheKey
@@ -155,7 +155,7 @@ implements Pipeable.Pipeable, Equal.Equal {
export const isQueryClientCacheKey = (u: unknown): u is QueryClientCacheKey => Predicate.hasProperty(u, QueryClientCacheKeyTypeId)
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheEntry")
export const QueryClientCacheEntryTypeId: unique symbol = Symbol.for("@effect-fc/QueryClient/QueryClientCacheEntry")
export type QueryClientCacheEntryTypeId = typeof QueryClientCacheEntryTypeId
export class QueryClientCacheEntry
@@ -5,7 +5,7 @@ import * as Component from "./Component.js"
import * as ScopeRegistry from "./ScopeRegistry.js"
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-view/ReactRuntime/ReactRuntime")
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime")
export type ReactRuntimeTypeId = typeof ReactRuntimeTypeId
export interface ReactRuntime<R, ER> {
+1 -1
View File
@@ -4,7 +4,7 @@
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/effect-fc-example` directory.
Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
## Peer dependencies
- `effect` 3.19+
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "effect-fc",
"description": "Write React function components with Effect",
"version": "0.3.1",
"version": "0.3.0",
"type": "module",
"files": [
"./README.md",
@@ -9,7 +9,7 @@
],
"license": "MIT",
"repository": {
"url": "git+https://github.com/Thiladev/effect-view.git"
"url": "git+https://github.com/Thiladev/effect-fc.git"
},
"types": "./dist/index.d.ts",
"exports": {
@@ -50,6 +50,6 @@
"react": "^19.2.0"
},
"dependencies": {
"effect-lens": "^0.2.3"
"effect-lens": "^0.2.0"
}
}
+4 -8
View File
@@ -273,7 +273,6 @@ describe("Component", () => {
)
await screen.findByText("first")
vi.useFakeTimers()
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -281,19 +280,16 @@ describe("Component", () => {
</runtime.context.Provider>
)
expect(screen.getByText("second")).toBeTruthy()
await screen.findByText("second")
expect(cleanup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(19)
await new Promise(resolve => setTimeout(resolve, 5))
expect(cleanup).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(cleanup).toHaveBeenCalledWith("first")
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 })
view.unmount()
await vi.advanceTimersByTimeAsync(20)
expect(cleanup).toHaveBeenCalledWith("second")
vi.useRealTimers()
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 })
await Effect.runPromise(runtime.runtime.disposeEffect)
})
+10
View File
@@ -0,0 +1,10 @@
# Effect FC Next Example
Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`.
```bash
bun run dev
```
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
rendered through an Effect-FC component.
@@ -4,7 +4,7 @@
<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>
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
@@ -1,9 +1,10 @@
{
"name": "@effect-fc/example",
"name": "@effect-fc/example-next",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint",
@@ -13,6 +14,7 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect-view/vite-plugin": "workspace:*",
"@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13",
@@ -22,21 +24,18 @@
"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",
"@effect/platform-browser": "4.0.0-beta.98",
"@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2",
"effect": "^3.21.2",
"effect-fc": "workspace:*",
"effect": "4.0.0-beta.98",
"effect-fc-next": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "^3.21.2",
"effect": "4.0.0-beta.98",
"react": "^19.2.6"
}
}
View File
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { Component, Form, View } from "effect-fc-next"
import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={isCommitting}
{...Struct.omit(props, "form")}
{...Struct.omit(props, ["form"])}
>
{isValidating &&
<TextField.Slot side="right">
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { Component, Form, View } from "effect-fc-next"
import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting}
{...Struct.omit(props, "form", "defaultValue")}
{...Struct.omit(props, ["form", "defaultValue"])}
>
<TextField.Slot side="left">
<Switch
@@ -1,24 +1,21 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactRuntime } from "effect-fc"
import { ReactRuntime } from "effect-fc-next"
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
}
interface Register { router: typeof router }
}
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} />
</ReactRuntime.Provider>
</StrictMode>
</StrictMode>,
)
@@ -9,33 +9,16 @@
// 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'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as FormRouteImport } from './routes/form'
import { Route as LensformRouteImport } from './routes/lensform'
import { Route as QueryRouteImport } from './routes/query'
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',
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AsyncRoute = AsyncRouteImport.update({
@@ -43,19 +26,24 @@ const AsyncRoute = AsyncRouteImport.update({
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
getParentRoute: () => rootRouteImport,
} as any)
const DevMemoRoute = DevMemoRouteImport.update({
id: '/dev/memo',
path: '/dev/memo',
const FormRoute = FormRouteImport.update({
id: '/form',
path: '/form',
getParentRoute: () => rootRouteImport,
} as any)
const DevContextRoute = DevContextRouteImport.update({
id: '/dev/context',
path: '/dev/context',
const LensformRoute = LensformRouteImport.update({
id: '/lensform',
path: '/lensform',
getParentRoute: () => rootRouteImport,
} as any)
const QueryRoute = QueryRouteImport.update({
id: '/query',
path: '/query',
getParentRoute: () => rootRouteImport,
} as any)
@@ -64,20 +52,16 @@ export interface FileRoutesByFullPath {
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/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
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -85,42 +69,15 @@ export interface FileRoutesById {
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/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'
fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -128,40 +85,17 @@ export interface RootRouteChildren {
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute
LensformRoute: typeof LensformRoute
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
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/async': {
@@ -171,25 +105,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
'/blank': {
id: '/blank'
path: '/blank'
fullPath: '/blank'
preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/memo': {
id: '/dev/memo'
path: '/dev/memo'
fullPath: '/dev/memo'
preLoaderRoute: typeof DevMemoRouteImport
'/form': {
id: '/form'
path: '/form'
fullPath: '/form'
preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/context': {
id: '/dev/context'
path: '/dev/context'
fullPath: '/dev/context'
preLoaderRoute: typeof DevContextRouteImport
'/lensform': {
id: '/lensform'
path: '/lensform'
fullPath: '/lensform'
preLoaderRoute: typeof LensformRouteImport
parentRoute: typeof rootRouteImport
}
'/query': {
id: '/query'
path: '/query'
fullPath: '/query'
preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport
}
}
@@ -200,10 +141,8 @@ const rootRouteChildren: RootRouteChildren = {
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute,
FormRoute: FormRoute,
LensformRoute: LensformRoute,
QueryRoute: QueryRoute,
ResultRoute: ResultRoute,
DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -7,7 +7,7 @@ import "../index.css"
export const Route = createRootRoute({
component: Root
component: Root,
})
function Root() {
@@ -17,6 +17,10 @@ function Root() {
<Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link>
<Link to="/blank">Blank</Link>
<Link to="/async">Async</Link>
<Link to="/query">Query</Link>
<Link to="/form">Form</Link>
<Link to="/lensform">LensForm</Link>
</Flex>
</Container>
@@ -1,30 +1,35 @@
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 { Console, Effect } from "effect"
import { Async, Component, Memoized } from "effect-fc-next"
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,
})
const fetchPost = (id: number) => Effect.sleep("500 millis").pipe(
Effect.as({
title: `Post ${id}`,
body: `This is the content of post ${id}.`,
}),
)
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])
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
props: AsyncFetchPostViewProps,
) {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted"))
yield* Console.log("AsyncFetchPostView mounted")
}))
const post = yield* Component.useOnChange(
() => fetchPost(props.id),
[props.id],
)
return (
<div>
@@ -34,10 +39,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
Memoized.memoized,
) {}
)
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
@@ -50,20 +54,22 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={text}
onChange={e => setText(e.currentTarget.value)}
onChange={event => setText(event.currentTarget.value)}
/>
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/>
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
<AsyncFetchPost id={id} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context)
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/async")({
+82
View File
@@ -0,0 +1,82 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Schema } from "effect"
import { Component, Form, MutationForm, View } from "effect-fc-next"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { runtime } from "@/runtime"
const RegisterSchema = Schema.Struct({
email: Schema.String.check(
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
message: "Enter a valid email address",
}),
),
password: Schema.String.check(
Schema.isMinLength(5, {
message: "Password must be at least 5 characters long",
}),
),
})
const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("Form route unmounted"))
yield* Console.log("Form route mounted")
}))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.service({
schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`),
})
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
return [form, emailField, passwordField] as const
}))
const [canCommit, isCommitting] = yield* View.useAll([
form.canCommit,
form.isCommitting,
])
const TextFieldFormInput = yield* TextFieldFormInputView.use
const runPromise = yield* Component.useRunPromise()
return (
<Container width="300">
<form onSubmit={event => {
event.preventDefault()
void runPromise(form.submit)
}}>
<Flex direction="column" gap="2">
<TextFieldFormInput
form={emailField}
placeholder="Email"
debounce="250 millis"
/>
<TextFieldFormInput
form={passwordField}
placeholder="Password"
type="password"
debounce="250 millis"
/>
<Button disabled={!canCommit || isCommitting}>
{isCommitting ? "Submitting…" : "Submit"}
</Button>
</Flex>
</form>
<Text size="2">A MutationForm validates local input, then submits it.</Text>
</Container>
)
}).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/form")({
component: RegisterRouteComponent,
})
@@ -0,0 +1,56 @@
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-fc-next"
import { runtime } from "@/runtime"
const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
const todosLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make<readonly string[]>([]),
Lens.fromSubscriptionRef,
))
const draftLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(""),
Lens.fromSubscriptionRef,
))
const [todos] = yield* View.useAll([todosLens])
const [draft, setDraft] = yield* Lens.useState(draftLens)
const runPromise = yield* Component.useRunPromise()
const addTodo = Lens.update(todosLens, todos =>
draft.trim() === ""
? todos
: [...todos, draft.trim()],
).pipe(
Effect.andThen(Lens.set(draftLens, "")),
)
return (
<Container width="480">
<Flex direction="column" gap="3">
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
<Flex gap="2">
<TextField.Root
value={draft}
onChange={event => setDraft(event.currentTarget.value)}
/>
<Button onClick={() => void runPromise(addTodo)}>
Add
</Button>
</Flex>
{todos.map(todo => <Text key={todo}> {todo}</Text>)}
</Flex>
</Container>
)
}).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/")({
component: TodoRouteComponent,
})
@@ -1,7 +1,7 @@
import { Container, Flex } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Context, Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Form, Lens, LensForm } from "effect-view"
import { Component, Form, Lens, LensForm } from "effect-fc-next"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { runtime } from "@/runtime"
+102
View File
@@ -0,0 +1,102 @@
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/unstable/http"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface PostResultViewProps {
readonly result: AsyncResult.AsyncResult<typeof Post.Type, Error>
}
const PostResultView = (props: PostResultViewProps) => AsyncResult.match(props.result, {
onInitial: result => result.waiting
? <Text>Loading...</Text>
: <Text>No data.</Text>,
onFailure: result => <Text>Request failed: { result.cause.toString() }</Text>,
onSuccess: result => <>
{result.waiting && <Text>Refreshing...</Text>}
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>,
})
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const idLens = Lens.focusTupleAt(keyLens, 1)
const query = yield* Query.service({
key: keyLens,
f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("1 second")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("1 second")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
})
return [idLens, query, mutation] as const
}))
const [id, setId] = yield* Lens.useState(idLens)
const [queryState, mutationState] = yield* View.useAll([query.state, mutation.state])
const runSync = yield* Component.useRunSync()
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
value={[id]}
min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/>
<PostResultView result={queryState.result} />
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runSync(query.refreshView)}>
Refresh
</Button>
<Button onClick={() => runSync(query.invalidateCache)}>
Invalidate cache
</Button>
</Flex>
<PostResultView result={mutationState} />
<Button onClick={() => runSync(mutation.mutateView([id]))}>
Mutate
</Button>
</Flex>
</Container>
)
}).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/query")({
component: QueryRouteComponent,
})
@@ -1,10 +1,11 @@
import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { DateTime, Layer } from "effect"
import { ReactRuntime } from "effect-fc"
import { FetchHttpClient } from "effect/unstable/http"
import { QueryClient, ReactRuntime } from "effect-fc-next"
export const AppLive = Layer.empty.pipe(
export const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer()),
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer),
@@ -12,4 +13,4 @@ export const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
export const runtime = ReactRuntime.make(layer)
@@ -1,5 +1,6 @@
import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { effectViewPlugin } from "@effect-view/vite-plugin"
import path from "node:path"
import { defineConfig } from "vite"
@@ -7,6 +8,7 @@ import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [
effectViewPlugin(),
tanstackRouter({
target: "react",
autoCodeSplitting: true,
+46 -6
View File
@@ -1,10 +1,50 @@
# Effect View Example
# React + TypeScript + Vite
Minimal React example for `effect-view`, Effect V4, and `effect-lens@2`.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
```bash
bun run dev
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,
},
},
})
```
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
rendered through an Effect View component.
- 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,
},
})
```
+1 -1
View File
@@ -4,7 +4,7 @@
<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 View Example</title>
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
+8 -7
View File
@@ -1,10 +1,9 @@
{
"name": "@effect-view/example",
"name": "@effect-fc/example",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint",
@@ -14,7 +13,6 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect-view/vite-plugin": "workspace:*",
"@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13",
@@ -24,18 +22,21 @@
"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-browser": "4.0.0-beta.101",
"@effect/platform": "^0.96.1",
"@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.101",
"effect-view": "workspace:*",
"@typed/id": "^0.17.2",
"effect": "^3.21.2",
"effect-fc": "workspace:*",
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.15",
"effect": "4.0.0-beta.101",
"effect": "^3.21.2",
"react": "^19.2.6"
}
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-view"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={isCommitting}
{...Struct.omit(props, ["form"])}
{...Struct.omit(props, "form")}
>
{isValidating &&
<TextField.Slot side="right">
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-view"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting}
{...Struct.omit(props, ["form", "defaultValue"])}
{...Struct.omit(props, "form", "defaultValue")}
>
<TextField.Slot side="left">
<Switch
+7 -4
View File
@@ -1,21 +1,24 @@
import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactRuntime } from "effect-view"
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 }
interface Register {
router: typeof router
}
}
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} />
</ReactRuntime.Provider>
</StrictMode>,
</StrictMode>
)

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