Finalize the Effect View rename and refresh docs, examples, and tooling #59

Merged
Thilawyn merged 12 commits from next into master 2026-07-27 04:11:42 +02:00
6 changed files with 175 additions and 49 deletions
Showing only changes of commit d07f73e511 - Show all commits
+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.
+17 -43
View File
@@ -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
+3 -3
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
@@ -271,4 +271,4 @@ Updating the focused `name` Lens through `Lens.useState` updates the parent
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