Finalize the Effect View rename and refresh docs, examples, and tooling (#59)
## Summary - Rename `effect-fc-next` to `effect-view` and update package metadata, internal symbols, imports, and repository references. - Promote the Effect 4 example to `packages/example` and move the legacy Effect 3 example to `packages/effect-fc-example`. - Add `effect-view` to the npm publishing workflow. - Update the Vite Fast Refresh plugin for `effect-view` and rename `effectViewPlugin()` to `effectView()`. - Upgrade Effect, React, TypeScript, build tooling, and related dependencies. - Refresh the documentation and homepage. ## Documentation - Add a dedicated Async guide covering: - Suspense fallbacks - Hook ordering - Memoization - Structural prop equality - Clarify synchronous and asynchronous component behavior. - Document runner scope and service requirements. - Explain stable construction of Query, Mutation, and Form instances. - Expand focused Lens and Form examples, including curried focus helpers. - Document the optional `@effect/platform-browser` dependency for window-focus query refresh. - Improve the Forms guide structure and examples. - Fix responsive homepage headline wrapping and update homepage copy. ## Notable API changes - Package rename: ```diff - effect-fc-next + effect-view --------- Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Async
|
||||
---
|
||||
|
||||
# Async
|
||||
|
||||
Effect View components run synchronously by default. Apply `Async.async` when a
|
||||
component must wait for an asynchronous Effect before it can return JSX. The
|
||||
component suspends while the Effect runs and accepts React Suspense props such
|
||||
as `fallback`.
|
||||
|
||||
```tsx title="src/UserCard.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Async, Component } from "effect-view"
|
||||
import { loadUser } from "./api"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(Async.async)
|
||||
```
|
||||
|
||||
Pass a fallback when rendering the component:
|
||||
|
||||
```tsx
|
||||
const User = yield* UserCard.use
|
||||
|
||||
return <User userId="123" fallback={<p>Loading user...</p>} />
|
||||
```
|
||||
|
||||
Or configure a default fallback once:
|
||||
|
||||
```tsx
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
|
||||
)
|
||||
```
|
||||
|
||||
## Hook ordering
|
||||
|
||||
**Important:** Do not use React hooks or Effect View hook helpers after an
|
||||
asynchronous computation in the component body. After the computation suspends,
|
||||
the generator continuation runs outside React's synchronous render phase.
|
||||
|
||||
Place every hook before the first operation that may suspend:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react"
|
||||
|
||||
const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
// Hooks and hook helpers go before asynchronous work.
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
// This may suspend, so no hooks may be used after it.
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<button onClick={() => setShowDetails((value) => !value)}>
|
||||
{user.name}
|
||||
</button>
|
||||
{showDetails && <p>{user.bio}</p>}
|
||||
</article>
|
||||
)
|
||||
},
|
||||
).pipe(Async.async)
|
||||
```
|
||||
|
||||
## Memoization
|
||||
|
||||
An async computation starts whenever its component renders. Apply
|
||||
`Memoized.memoized` after `Async.async` to prevent an unrelated parent render
|
||||
from restarting an async child whose props have not changed:
|
||||
|
||||
```tsx
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* ({ userId }: { readonly userId: string }) {
|
||||
const user = yield* Component.useOnChange(
|
||||
() => loadUser(userId),
|
||||
[userId],
|
||||
)
|
||||
|
||||
return <article>{user.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
)
|
||||
```
|
||||
|
||||
Async components compare props with `Object.is` by default and ignore the
|
||||
`fallback` prop during that comparison. A changed `userId` renders the child
|
||||
again, closes the previous dependency scope, and runs `loadUser` for the new
|
||||
value.
|
||||
|
||||
If props contain immutable objects or arrays that may be recreated with the same
|
||||
content, use `Equal.asEquivalence()` for full structural equality:
|
||||
|
||||
```tsx
|
||||
import { Equal } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-view"
|
||||
|
||||
export const UserCard = Component.make("UserCard")(
|
||||
function* (props: { readonly user: { readonly id: string } }) {
|
||||
const details = yield* Component.useOnChange(
|
||||
() => loadUser(props.user.id),
|
||||
[props.user.id],
|
||||
)
|
||||
|
||||
return <article>{details.name}</article>
|
||||
},
|
||||
).pipe(
|
||||
Async.async,
|
||||
Memoized.memoized,
|
||||
Memoized.withOptions({
|
||||
propsEquivalence: Equal.asEquivalence(),
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Supplying `propsEquivalence` replaces the async component's default comparison,
|
||||
so `fallback` is also included in this structural comparison.
|
||||
+139
-121
@@ -52,6 +52,14 @@ 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
|
||||
@@ -102,9 +110,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. Use
|
||||
it inside `Component.useOnMount`, a scoped service, or another Effect scope so
|
||||
its validation and mutation work is cleaned up with its owner.
|
||||
`MutationForm.service` also starts initial validation in the current scope. In
|
||||
the example above, `Component.useOnMount` keeps that validation and the form's
|
||||
mutation work tied to the component that owns them.
|
||||
|
||||
## LensForm: validate, then synchronize
|
||||
|
||||
@@ -163,124 +171,6 @@ 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
|
||||
@@ -300,6 +190,16 @@ 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`
|
||||
@@ -455,3 +355,121 @@ 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.
|
||||
|
||||
@@ -44,16 +44,16 @@ Effect View components:
|
||||
npm install --save-dev @effect-view/vite-plugin
|
||||
```
|
||||
|
||||
Add `effectViewPlugin()` before the React plugin in your Vite configuration:
|
||||
Add `effectView()` before the React plugin in your Vite configuration:
|
||||
|
||||
```ts title="vite.config.ts"
|
||||
import { effectViewPlugin } from "@effect-view/vite-plugin"
|
||||
import { effectView } from "@effect-view/vite-plugin"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectViewPlugin(),
|
||||
effectView(),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
@@ -199,51 +199,25 @@ to provide local services to a subtree.
|
||||
|
||||
## Synchronous and asynchronous components
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Use `Async.async` when render genuinely depends on an asynchronous Effect. The
|
||||
component then suspends and accepts React Suspense props such as `fallback`:
|
||||
Choose the integration that matches the asynchronous work:
|
||||
|
||||
```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.
|
||||
- Make the component asynchronous with [`Async.async`](./async) when it must
|
||||
wait for a one-off asynchronous Effect before producing JSX. The component
|
||||
suspends while it waits.
|
||||
- Use [Query](./query) for server reads that need caching, sharing, refresh, or
|
||||
invalidation.
|
||||
- Use [Mutation](./mutation) for user-triggered writes with observable pending
|
||||
and error state.
|
||||
- Use `useRunPromise` or `useCallbackPromise` for asynchronous event work that
|
||||
does not need Mutation state.
|
||||
- Use a post-commit hook with a scoped fiber for subscriptions or background
|
||||
work tied to the component lifecycle.
|
||||
|
||||
## Use Effect services
|
||||
|
||||
@@ -321,24 +295,25 @@ 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 create a scope
|
||||
that can be replaced without closing the component root scope.
|
||||
`useOnChange`, `useReactEffect`, and `useReactLayoutEffect` each manage their
|
||||
own resources. When their dependencies change, those resources are cleaned up
|
||||
and recreated while the rest of the component remains active.
|
||||
|
||||
The main lifecycle choices are:
|
||||
|
||||
| Hook | What it does | Scope seen by setup | Scope closes when |
|
||||
| --- | --- | --- | --- |
|
||||
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
|
||||
| `useOnMount` | Computes and caches a value for the component instance | The same component root scope | The component unmounts |
|
||||
| `useOnMount` | Computes and caches a value for the component instance | Component root scope | The component unmounts |
|
||||
| `useOnChange` | Recomputes a cached value when dependencies change | A new dependency scope | Dependencies change or the component unmounts |
|
||||
| `useReactEffect` | Runs a post-commit side effect | A new effect scope | Dependencies change or the component unmounts |
|
||||
| `useReactLayoutEffect` | Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
|
||||
| `useLayer` | Builds and provides a layer context | A dependency scope created through `useOnChange` | The layer reference changes or the component unmounts |
|
||||
| `useLayer` | Builds and provides a layer context | A new scope tied to the current layer reference | The layer reference changes or the component unmounts |
|
||||
|
||||
`useRunSync`, `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.
|
||||
`useRunSync` and `useRunPromise` do not create a new scope. The runner they
|
||||
return includes the component root scope in its context by default.
|
||||
`useCallbackSync` and `useCallbackPromise` similarly capture the component
|
||||
context required by their Effect.
|
||||
|
||||
### useOnMount
|
||||
|
||||
@@ -436,6 +411,21 @@ 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:
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ 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"
|
||||
|
||||
@@ -35,7 +35,6 @@ 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(
|
||||
@@ -44,7 +43,6 @@ const AppLive = Layer.empty.pipe(
|
||||
defaultRefreshOnWindowFocus: true,
|
||||
cacheGcTime: "5 minutes",
|
||||
})),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
@@ -52,7 +50,9 @@ 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.
|
||||
client defaults. Window-focus refresh also requires the optional
|
||||
`@effect/platform-browser` package, as described under
|
||||
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
|
||||
|
||||
## Create a reactive query
|
||||
|
||||
@@ -60,6 +60,12 @@ 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"
|
||||
@@ -118,9 +124,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` creates the query and starts watching its key in the current
|
||||
scope. Creating it in `Component.useOnMount` keeps one query instance—and one
|
||||
stable query function identity—for the component's lifetime.
|
||||
`Query.service` starts watching its key in the current scope. The
|
||||
`Component.useOnMount` call above keeps both the query instance and its query
|
||||
function identity stable for the component's lifetime.
|
||||
|
||||
## Render AsyncResult
|
||||
|
||||
@@ -264,8 +270,17 @@ const query = yield* Query.service({
|
||||
})
|
||||
```
|
||||
|
||||
Window focus resolves the current key again, subject to the same freshness
|
||||
check. The browser integration is ignored in non-browser environments.
|
||||
Window-focus refresh depends on the optional `@effect/platform-browser`
|
||||
integration. Install it in browser applications that use this behavior:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install @effect/platform-browser@beta
|
||||
```
|
||||
|
||||
When the package is available, focusing the window resolves the current key
|
||||
again, subject to the same freshness check. Without it,
|
||||
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
|
||||
work normally. The integration is also ignored in non-browser environments.
|
||||
|
||||
## The Effect touch
|
||||
|
||||
|
||||
@@ -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://github.com/Thiladev/effect-lens) for convenience. The
|
||||
[`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) for convenience. The
|
||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/View API.
|
||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta) for the full Lens/View API.
|
||||
|
||||
The usual pattern is to create state with Effect primitives such as
|
||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||
@@ -219,15 +219,16 @@ 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 name: Lens.Lens<string>
|
||||
readonly role: Lens.Lens<string>
|
||||
}
|
||||
{ readonly profile: Lens.Lens<UserProfile> }
|
||||
>()("ProfileState") {
|
||||
static readonly layer = Layer.effect(
|
||||
ProfileState,
|
||||
@@ -237,20 +238,35 @@ 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, name, role } as const
|
||||
return { profile } as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ProfileNameView = Component.make("ProfileName")(function*() {
|
||||
const state = yield* ProfileState
|
||||
const [name, setName] = yield* Lens.useState(state.name)
|
||||
const [role] = yield* View.useAll([state.role])
|
||||
const [nameLens, roleLens, cityLens] = yield* Component.useOnMount(() =>
|
||||
Effect.succeed([
|
||||
Lens.focusObjectOn(state.profile, "name"),
|
||||
Lens.focusObjectOn(state.profile, "role"),
|
||||
state.profile.pipe(
|
||||
Lens.focusObjectOn("contact"),
|
||||
Lens.focusObjectOn("address"),
|
||||
Lens.focusObjectOn("city"),
|
||||
),
|
||||
] as const),
|
||||
)
|
||||
|
||||
const [name, setName] = yield* Lens.useState(nameLens)
|
||||
const [role, city] = yield* View.useAll([roleLens, cityLens])
|
||||
|
||||
return (
|
||||
<label>
|
||||
@@ -260,15 +276,22 @@ const ProfileNameView = Component.make("ProfileName")(function*() {
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<span>Role: {role}</span>
|
||||
<span>City: {city}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
||||
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
||||
`View.useAll` path.
|
||||
The service owns only the root `profile` Lens. The component chooses the fields
|
||||
it needs, creates those focused Lenses once in `Component.useOnMount`, and
|
||||
subscribes only to them. Updating `nameLens` through `Lens.useState` still
|
||||
updates the parent `profile` Lens.
|
||||
|
||||
Lens focus helpers have a dual API. They can be called in data-first form, such
|
||||
as `Lens.focusObjectOn(profile, "name")`, or in curried form with only the key
|
||||
or index. The `cityLens` above uses the curried form with `pipe` to focus through
|
||||
a deeper path without storing intermediate Lenses.
|
||||
|
||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||
complete API, refer to the
|
||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
||||
[`effect-lens` documentation](https://www.npmjs.com/package/effect-lens/v/beta).
|
||||
|
||||
@@ -6,6 +6,7 @@ const sidebars: SidebarsConfig = {
|
||||
docsSidebar: [
|
||||
"getting-started",
|
||||
"state-management",
|
||||
"async",
|
||||
"query",
|
||||
"mutation",
|
||||
"forms",
|
||||
|
||||
@@ -72,9 +72,15 @@
|
||||
letter-spacing: -0.075em;
|
||||
line-height: 0.92;
|
||||
margin: 0;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.hero h1 span {
|
||||
.hero h1 .unbreakable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero h1 .heroAccent {
|
||||
background: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
|
||||
@@ -100,8 +100,8 @@ export default function Home(): ReactNode {
|
||||
Effect View for React 19
|
||||
</div>
|
||||
<h1>
|
||||
React components,
|
||||
<span> powered by Effect.</span>
|
||||
React <span className={styles.unbreakable}>components,</span>
|
||||
<span className={styles.heroAccent}> powered by Effect.</span>
|
||||
</h1>
|
||||
<p className={styles.lede}>
|
||||
Bring typed services, scoped resources, reactive state, server
|
||||
|
||||
Reference in New Issue
Block a user