Finalize the Effect View rename and refresh docs, examples, and tooling (#59)
Publish / publish (push) Successful in 3m23s
Lint / lint (push) Successful in 48s

## Summary

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

## Documentation

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

## Notable API changes

- Package rename:

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

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
2026-07-27 04:11:42 +02:00
parent ec21d49dce
commit 4b712de788
111 changed files with 1536 additions and 1278 deletions
+145
View File
@@ -0,0 +1,145 @@
---
sidebar_position: 2
title: Async
---
# Async
Effect View components run synchronously by default. Apply `Async.async` when a
component must wait for an asynchronous Effect before it can return JSX. The
component suspends while the Effect runs and accepts React Suspense props such
as `fallback`.
```tsx title="src/UserCard.tsx"
import { Effect } from "effect"
import { Async, Component } from "effect-view"
import { loadUser } from "./api"
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return <article>{user.name}</article>
},
).pipe(Async.async)
```
Pass a fallback when rendering the component:
```tsx
const User = yield* UserCard.use
return <User userId="123" fallback={<p>Loading user...</p>} />
```
Or configure a default fallback once:
```tsx
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return <article>{user.name}</article>
},
).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
)
```
## Hook ordering
**Important:** Do not use React hooks or Effect View hook helpers after an
asynchronous computation in the component body. After the computation suspends,
the generator continuation runs outside React's synchronous render phase.
Place every hook before the first operation that may suspend:
```tsx
import { useState } from "react"
const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
// Hooks and hook helpers go before asynchronous work.
const [showDetails, setShowDetails] = useState(false)
// This may suspend, so no hooks may be used after it.
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return (
<article>
<button onClick={() => setShowDetails((value) => !value)}>
{user.name}
</button>
{showDetails && <p>{user.bio}</p>}
</article>
)
},
).pipe(Async.async)
```
## Memoization
An async computation starts whenever its component renders. Apply
`Memoized.memoized` after `Async.async` to prevent an unrelated parent render
from restarting an async child whose props have not changed:
```tsx
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(
function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(userId),
[userId],
)
return <article>{user.name}</article>
},
).pipe(
Async.async,
Memoized.memoized,
)
```
Async components compare props with `Object.is` by default and ignore the
`fallback` prop during that comparison. A changed `userId` renders the child
again, closes the previous dependency scope, and runs `loadUser` for the new
value.
If props contain immutable objects or arrays that may be recreated with the same
content, use `Equal.asEquivalence()` for full structural equality:
```tsx
import { Equal } from "effect"
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(
function* (props: { readonly user: { readonly id: string } }) {
const details = yield* Component.useOnChange(
() => loadUser(props.user.id),
[props.user.id],
)
return <article>{details.name}</article>
},
).pipe(
Async.async,
Memoized.memoized,
Memoized.withOptions({
propsEquivalence: Equal.asEquivalence(),
}),
)
```
Supplying `propsEquivalence` replaces the async component's default comparison,
so `fallback` is also included in this structural comparison.
+139 -121
View File
@@ -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 -54
View File
@@ -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:
+5
View File
@@ -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"
+23 -8
View File
@@ -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 instanceand one
stable query function identityfor 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
+39 -16
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://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).
+1
View File
@@ -6,6 +6,7 @@ const sidebars: SidebarsConfig = {
docsSidebar: [
"getting-started",
"state-management",
"async",
"query",
"mutation",
"forms",
+7 -1
View File
@@ -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;
+2 -2
View File
@@ -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