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 ## Synchronous and asynchronous components
A regular Effect View component runs its body during React render. That Effect A regular Effect View component runs its body during React render. Effects
must complete synchronously: yielding a service, reading synchronous state, or executed directly by that body—including setup passed to `useOnMount`,
creating a scoped object is fine; sleeping, fetching, or awaiting a promise is `useOnChange`, or `useLayer`—must complete synchronously every time they run.
not. 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 Choose the integration that matches the asynchronous work:
component then suspends and accepts React Suspense props such as `fallback`:
```tsx title="src/UserView.tsx" - Make the component asynchronous with [`Async.async`](./async) when it must
import { Async, Component, Memoized } from "effect-view" wait for a one-off asynchronous Effect before producing JSX. The component
import { loadUser } from "./api" suspends while it waits.
- Use [Query](./query) for server reads that need caching, sharing, refresh, or
export const UserView = Component.make("UserView")( invalidation.
function* (props: { readonly userId: string }) { - Use [Mutation](./mutation) for user-triggered writes with observable pending
const user = yield* Component.useOnChange( and error state.
() => loadUser(props.userId), - Use `useRunPromise` or `useCallbackPromise` for asynchronous event work that
[props.userId], does not need Mutation state.
) - Use a post-commit hook with a scoped fiber for subscriptions or background
work tied to the component lifecycle.
return <h2>{user.name}</h2>
},
).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
Memoized.memoized,
)
```
Use it from an Effect View parent in the usual way:
```tsx
const User = yield* UserView.use
return <User userId={selectedUserId} />
```
`Memoized.memoized` prevents an unrelated parent re-render from restarting an
async child whose props have not changed. A changed `userId` creates a new
dependency scope, cleans up the previous one, and runs `loadUser` again.
An async component's rejected Effect is handled by the nearest React error
boundary. Suspense handles waiting, not failures.
For server data that should be cached, refreshed, and shared, prefer the
[Query module](./query) over a raw async component.
## Use Effect services ## 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. 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-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 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 The usual pattern is to create state with Effect primitives such as
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor `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 For focusing into nested state, deriving lenses, custom write behavior, and the
complete API, refer to 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: [ docsSidebar: [
"getting-started", "getting-started",
"state-management", "state-management",
"async",
"query", "query",
"mutation", "mutation",
"forms", "forms",
+7 -1
View File
@@ -72,9 +72,15 @@
letter-spacing: -0.075em; letter-spacing: -0.075em;
line-height: 0.92; line-height: 0.92;
margin: 0; 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: linear-gradient(105deg, var(--home-teal) 5%, #1a9793 48%, var(--home-amber));
background-clip: text; background-clip: text;
color: transparent; color: transparent;
+2 -2
View File
@@ -100,8 +100,8 @@ export default function Home(): ReactNode {
Effect View for React 19 Effect View for React 19
</div> </div>
<h1> <h1>
React components, React <span className={styles.unbreakable}>components,</span>
<span> powered by Effect.</span> <span className={styles.heroAccent}> powered by Effect.</span>
</h1> </h1>
<p className={styles.lede}> <p className={styles.lede}>
Bring typed services, scoped resources, reactive state, server Bring typed services, scoped resources, reactive state, server