{value.title}
+{value.body}
+diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml
index c13cd5d..0a3d7b6 100644
--- a/.gitea/workflows/lint.yaml
+++ b/.gitea/workflows/lint.yaml
@@ -16,3 +16,5 @@ jobs:
run: bun lint:tsc
- name: Lint Biome
run: bun lint:biome
+ - name: Test
+ run: bun run test
diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml
index f20291c..4154609 100644
--- a/.gitea/workflows/publish.yaml
+++ b/.gitea/workflows/publish.yaml
@@ -11,14 +11,26 @@ 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-fc
@@ -28,3 +40,30 @@ jobs:
access: public
token: ${{ secrets.NPM_TOKEN }}
registry: https://registry.npmjs.org
+
+ - name: Clean before Docker build
+ run: |
+ bun clean:cache
+ bun clean:dist
+ bun clean:modules
+ - name: Generate Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ docker.valverde.cloud/${{ gitea.repository }}
+ 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 }}
diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml
index 729eb00..87f6c74 100644
--- a/.gitea/workflows/test-build.yaml
+++ b/.gitea/workflows/test-build.yaml
@@ -12,16 +12,49 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
- node-version: "24"
+ node-version: "22"
+ - 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/${{ gitea.repository }}
+ 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 }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..6dac0e3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,12 @@
+FROM oven/bun:1.3.12-debian@sha256:1b709c9dd883fc1af38c210f7ea5222c552a8d470ea73efbd4b8fcfee798a64b AS bun
+
+FROM node:22.21.1-trixie-slim@sha256:98e1429d1a0b99378b4de43fa385f0746fd6276faf4feeb6104d91f6bad290f9
+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
diff --git a/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md b/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md
new file mode 100644
index 0000000..246b1a5
--- /dev/null
+++ b/EFFECT_FC_NEXT_ERROR_HANDLING_REPORT.md
@@ -0,0 +1,175 @@
+# 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-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.
+
+The better model is:
+
+1. Handle expected, typed failures where the operation is rendered or invoked.
+2. Report unhandled/background failures through one explicit runtime boundary.
+3. Let React error boundaries render defects and render-time failures.
+
+In particular, queries, mutations, and forms should keep their current
+`AsyncResult`/validation state as the user-facing error path; a global service
+should not replace that state.
+
+## What exists today
+
+`packages/effect-fc-next/src/ErrorObserver.ts` provides a context service
+backed by an unbounded `PubSub
+
+
+ Write React components as typed Effect programs.
+
+ }
+
+ export type Signature = <
+ P extends readonly PropertyKey[],
+ A,
+ ER,
+ EW,
+ >(
+ props: Props ,
+ ) => React.ReactNode
+}
+
+export const TextFieldFormInputView = Component.make(
+ "TextFieldFormInputView",
+)(function* (
+ props: TextFieldFormInputView.Props<
+ readonly PropertyKey[],
+ any,
+ any,
+ any
+ >,
+) {
+ const input = yield* Form.useInput(props.form, props)
+ const [issues, isValidating, isCommitting] = yield* View.useAll([
+ props.form.issues,
+ props.form.isValidating,
+ props.form.isCommitting,
+ ])
+
+ return (
+
+
+
Effect View Monorepo
-This monorepo contains:
-- [The `effect-fc` library](packages/effect-fc)
-- [An example project](packages/example)
+
Both components use the same Effect context.
+Loading user...
}), + Memoized.memoized, +) +``` + +Use it from an Effect View parent in the usual way: + +```tsx +const User = yield* UserView.use + +return{greeting.greet(props.name)}
+ }, +) +``` + +Add `GreetingService.layer` to the application runtime, or provide it only to a +subtree as shown later on this page. + +Effect service instances are reactive at component boundaries. If the supplied +context changes to contain a different service instance, dependent Effect View +components are recreated so they read the new environment and restart their +scoped lifecycle. + +## Understand lifecycle hooks + +Effect View hooks are still React hooks internally. Call them unconditionally +at the top level of the component body, in a consistent order, and never inside +branches, loops, event handlers, or nested callbacks. + +### The component root scope + +Every rendered Effect View component gets a root `Scope.Scope`. Effect View +creates it when the component instance is rendered, provides it to the entire +component body, and closes it when that React component unmounts. + +Effects yielded directly from the body therefore see the root scope. This also +means that `Effect.addFinalizer`, `Effect.acquireRelease`, and +`Effect.forkScoped` can be used naturally inside component setup: + +```tsx +import { Effect } from "effect" +import { Component } from "effect-view" + +const ResourceView = Component.make("Resource")(function* () { + const resource = yield* Component.useOnMount(() => + Effect.acquireRelease( + openResource, + (resource) => closeResource(resource), + ), + ) + + return{resource.name}
+}) +``` + +`useOnMount` does **not** create or provide another scope. It runs its Effect +with the context already available in the component body, so the resource above +is owned by the component's root scope. + +`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each 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 | 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 dependency scope created through `useOnChange` | The layer reference changes or the component unmounts | + +`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 + +`Component.useOnMount` computes a value during the initial render and caches it +for later renders. It uses the component root scope, so its resources live +until the component unmounts. + +```tsx title="src/LocalStateView.tsx" +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, View } from "effect-view" + +export const LocalStateView = Component.make("LocalState")( + function* () { + const count = yield* Component.useOnMount(() => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.log("LocalState disposed"), + ) + + return Lens.fromSubscriptionRef( + yield* SubscriptionRef.make(0), + ) + }), + ) + + const [value] = yield* View.useAll([count]) + returnCount: {value}
+ }, +) +``` + +In a regular component, the setup Effect must be synchronous because it runs +during render. In a component enhanced with `Async.async`, it may be +asynchronous and will suspend the component. + +### useOnChange + +`Component.useOnChange` computes and caches a value, then recomputes it when its +dependencies change. Each dependency set gets its own scope, which closes when +the dependencies change or the component unmounts. + +```tsx +const label = yield* Component.useOnChange( + () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.log(`Stopped viewing ${props.userId}`), + ) + + return `Viewing user ${props.userId}` + }), + [props.userId], +) +``` + +Dependency arrays follow normal React semantics. Include every reactive value +read by the setup Effect. + +### useReactEffect and useReactLayoutEffect + +`Component.useReactEffect` runs side effects after React commits, while +`Component.useReactLayoutEffect` runs before the browser paints. Each hook owns +a scope that closes when its dependencies change or the component unmounts. + +Setup must complete synchronously, but it can fork asynchronous work into the +hook scope: + +```tsx +yield* Component.useReactEffect( + () => Effect.forkScoped(listenForNotifications(props.userId)), + [props.userId], +) +``` + +React Strict Mode may intentionally repeat development-only render and effect +setup. Initializers and resource acquisition should therefore be safe to run +more than once; production retains the normal component lifetime semantics. + +## Run Effects from event handlers + +React event handlers are plain functions. Use Effect View runners to execute an +Effect with the component's current context and scope: + +```tsx +const runPromise = yield* Component.useRunPromise() + +return ( + +) +``` + +Use `Component.useRunSync` only for Effects known to complete synchronously. +Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or +otherwise continue asynchronously. + +When a callback is passed to a memoized child or used as a dependency, use the +callback variants to preserve its identity: + +```tsx +const save = yield* Component.useCallbackPromise( + (nextUser: User) => saveUser(nextUser), + [saveUser], +) + +returnCould not send invite: {cause.toString()}
+ ), + onSuccess: ({ value }) => ( +Invite sent to {value.email}
+ ), + })} +Starting...
:Ready.
, + onFailure: ({ cause, previousSuccess, waiting }) => ( +{cause.toString()}
+ {previousSuccess._tag === "Some" && ( +Last saved value: {previousSuccess.value.value.name}
+ )} + {waiting &&Trying again...
} +Saved {value.name}
+ {waiting &&Saving a newer value...
} +{state.result._tag}
+ Loading...
:Not loaded.
, + onFailure: ({ cause, previousSuccess, waiting }) => ( +Request failed: {cause.toString()}
+ {previousSuccess._tag === "Some" && ( +Last post: {previousSuccess.value.value.title}
+ )} + {waiting &&Trying again...
} +{value.body}
+Count: {count}
+}) +``` + +If state belongs to a single Effect View component instance, or to a shallow +hierarchy of subcomponents that receive it through props, create it with +`Component.useOnMount`: + +```tsx +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, View } from "effect-view" + +const LocalCounterView = Component.make("LocalCounter")(function*() { + const state = yield* Component.useOnMount(() => + Effect.gen(function*() { + const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0)) + + return { count } as const + }), + ) + const [count] = yield* View.useAll([state.count]) + + returnCount: {count}
+}) +``` + +For simple UI state that is not shared and does not need Effect integration, +prefer regular React state. A local "show details" toggle is usually better as +`React.useState(false)` than as a Lens. + +## View.useAll + +A `View` is reactive state with a current value and a stream of changes. Use +`View.useAll` whenever a component needs to bind View values into render output. + +`Lens` is a `View`, so this is also the default way to read Lens values +from a component. + +```tsx +import { Context, Effect, Layer, SubscriptionRef } from "effect" +import { Component, Lens, View } from "effect-view" + +class CounterState extends Context.Service< + CounterState, + { + readonly count: Lens.LensCount: {count}, doubled: {doubled}
+ }, +) +``` + +`View.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive. + +When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`: + +```tsx +const CounterControlsView = Component.make("CounterControls")( + function* () { + const state = yield* CounterState + const [count] = yield* View.useAll([state.count]) + + const increment = yield* Component.useCallbackSync( + () => Lens.update(state.count, (n) => n + 1), + [], + ) + const reset = yield* Component.useCallbackSync( + () => Lens.set(state.count, 0), + [], + ) + + return ( +Count: {count}
+ + +This is a React page
-docs directory.
- >
- ),
- },
- {
- title: 'Powered by React',
- Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
- description: (
- <>
- Extend or customize your website layout by reusing React. Docusaurus can
- be extended while reusing the same header and footer.
- >
- ),
- },
-];
-
-function Feature({title, Svg, description}: FeatureItem) {
- return (
- {description}
-{siteConfig.tagline}
-{user.email}
++ Bring typed services, scoped resources, reactive state, server + queries, and schema-driven forms into React without hiding either + framework. +
+ +npm install effect-view effect@beta
+ Why Effect View
++ Effect View adds the pieces React deliberately leaves open while + preserving the React component model you already know. +
+{feature.description}
+{feature.tag}
+
+ Ready when React is
++ Add a runtime, cross the React boundary once, and grow into the + rest of the toolkit only when you need it. +
+This component is still running inside Effect-FC.
+{message}
+ }, +) +``` + +Use `Component.useOnMount` when the component needs a value produced by an +Effect during its first render. The value is then cached for the component +instance. You can also use it to set up scoped component logic, subscriptions, +or resources that should live for the lifetime of that component instance. + +Use `Component.useOnChange` when render needs the value computed by +scoped work that depends on changing inputs. When a dependency changes, React +re-renders the component and the hook computes the next value during that +render. + +```tsx +import { Console, Effect } from "effect" +import { Component } from "effect-fc" + +const UserPanelView = Component.make("UserPanel")( + function* (props: { readonly userId: string }) { + const label = yield* Component.useOnChange( + () => + Effect.gen(function* () { + yield* Console.log(`Preparing view for ${props.userId}`) + yield* Effect.addFinalizer(() => + Console.log(`Cleaning up ${props.userId}`), + ) + + return `Viewing user ${props.userId}` + }), + [props.userId], + ) + + return{label}
+ }, +) +``` + +In this example, each `userId` gets its own scope. When `userId` changes, +Effect-FC closes the previous scope, runs its finalizers, and creates a new +scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the +component's root scope directly. It creates and provides its own scope for that +dependency window. Some other Effect-FC hooks follow the same pattern when they +need a lifecycle that is narrower than the whole component instance. + +## Useful Effect-FC Hooks + +Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run +them inside the component body. + +The most common Effect-FC hooks are: + +- `Component.useOnMount`: run an Effect once during the component's first render + after mount, and return its value. The Effect must be synchronous. + +```tsx +const initialData = yield* Component.useOnMount(() => loadInitialData) +``` + +- `Component.useOnChange`: when a dependency changes, React re-renders the + component and the Effect is run again inside the component body. It returns + the latest value, so the Effect must be synchronous too. + +```tsx +const label = yield* Component.useOnChange(() => formatUserLabel(id), [id]) +``` + +- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects + that do not need to compute render output or trigger a re-render. The Effect + must be synchronous and can register scoped finalizers. + +```tsx +yield* Component.useReactEffect( + () => Effect.forkScoped(subscribeToUser(id)), + [id], +) +``` + +- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped + finalizers. + +```tsx +yield* Component.useReactLayoutEffect(() => measure(ref), []) +``` + +- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React + event handlers. + +```tsx +const runPromise = yield* Component.useRunPromise() + +return ( + +) +``` + +- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable + React callbacks. + +```tsx +const save = yield* Component.useCallbackPromise( + (user: User) => saveUser(user), + [], +) + +return +``` + +## Use React Normally + +An Effect-FC component is still a React function component. Anything you can do +in a regular React component can also be done in an Effect-FC component, +including React hooks, refs, event handlers, context, and JSX composition. + +```tsx +import { Component } from "effect-fc" +import * as React from "react" + +const CounterView = Component.make("Counter")(function* () { + const [count, setCount] = React.useState(0) + const buttonRef = React.useRef{greeting.greet(props.name)}
+}) +``` + +Service values in the runtime context are reactive by default. If a provided +service instance changes, Effect-FC unmounts and mounts again the components +that depend on that context, so they read the new service and restart their +scoped lifecycle with the new environment. For services that should not trigger +that behavior, pass their tags with `Component.withOptions({ nonReactiveTags: +[...] })`. + +## Provide Services To A Subtree + +Use `Component.useContextFromLayer` when an Effect-FC component should provide +extra services to another Effect-FC component. It turns a `Layer` into a runtime +context that can be provided to `.use`. + +```tsx title="src/GreetingPageView.tsx" +import { Effect } from "effect" +import { Component } from "effect-fc" +import { GreetingView } from "./Greeting" +import { GreetingService } from "./services" + +const GreetingLive = GreetingService.Default({ + greet: (name: string) => `Welcome, ${name}`, +}) + +export const GreetingPageView = Component.make("GreetingPage")(function* () { + const context = yield* Component.useContextFromLayer(GreetingLive) + const Greeting = yield* Effect.provide(GreetingView.use, context) + + returnCount: {count}
+}) +``` + +If state belongs to a single Effect-FC component instance, or to a shallow +hierarchy of subcomponents that receive it through props, create it with +`Component.useOnMount`: + +```tsx +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, Subscribable } from "effect-fc" + +const LocalCounterView = Component.make("LocalCounter")(function* () { + const state = yield* Component.useOnMount(() => + Effect.gen(function* () { + const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0)) + + return { count } as const + }), + ) + const [count] = yield* Subscribable.useAll([state.count]) + + returnCount: {count}
+}) +``` + +For simple UI state that is not shared and does not need Effect integration, +prefer regular React state. A local "show details" toggle is usually better as +`React.useState(false)` than as a Lens. + +## Subscribable.useAll + +A `Subscribable` is reactive state with a current value and a stream of +changes. Use `Subscribable.useAll` whenever a component needs to bind +subscribable values into render output. + +`Lens` is a `Subscribable`, so this is also the default way to read Lens values +from a component. + +```tsx +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, Subscribable } from "effect-fc" + +class CounterState extends Effect.ServiceCount: {count}, doubled: {doubled}
+ }, +) +``` + +`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive. + +When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`: + +```tsx +const CounterControlsView = Component.make("CounterControls")( + function* () { + const state = yield* CounterState + const [count] = yield* Subscribable.useAll([state.count]) + + const increment = yield* Component.useCallbackSync( + () => Lens.update(state.count, (n) => n + 1), + [], + ) + const reset = yield* Component.useCallbackSync( + () => Lens.set(state.count, 0), + [], + ) + + return ( +Count: {count}
+ + +
+
+
+
+
+ Write React components as typed Effect programs. +
+ + + + + +Effect View brings Effect's typed services, resource safety, concurrency, and +data modeling into React 19 without replacing React's component model. Yield +Effects and services from a component body, let scopes follow the React +lifecycle, and return ordinary JSX. + +> **Effect v4 beta:** Effect View is built for the Effect v4 beta release. If +> your application uses Effect v3, use the legacy +> [`effect-fc` package](https://www.npmjs.com/package/effect-fc) instead. + +```bash +npm install effect-view effect@beta react +``` + +Effect View does not depend on `react-dom`. Install the renderer used by your +application—for example, `react-dom` for the web: + +```bash +npm install react-dom +``` + +You can use Effect View with React Native or any other React renderer instead. + +## What it looks like + +An Effect View component is an Effect program that produces JSX. It can create +scoped state, access services, and use React-facing hooks without leaving the +generator model: + +```tsx +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens } from "effect-view" +import { runtime } from "./runtime" + +const CounterValueView = Component.make("CounterValue")( + function* ({ count }: { readonly count: Lens.Lens( + this: Component.Component
& Async,
+ contextRef: React.RefObject Loading... Loading...