From d07f73e511a5a759ca01c518ffda2acb0e06beeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 27 Jul 2026 02:48:26 +0200 Subject: [PATCH] Update docs --- packages/docs/docs/async.md | 145 +++++++++++++++++++++++ packages/docs/docs/getting-started.md | 60 +++------- packages/docs/docs/state-management.md | 6 +- packages/docs/sidebars.ts | 1 + packages/docs/src/pages/index.module.css | 8 +- packages/docs/src/pages/index.tsx | 4 +- 6 files changed, 175 insertions(+), 49 deletions(-) create mode 100644 packages/docs/docs/async.md diff --git a/packages/docs/docs/async.md b/packages/docs/docs/async.md new file mode 100644 index 0000000..2b91833 --- /dev/null +++ b/packages/docs/docs/async.md @@ -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
{user.name}
+ }, +).pipe(Async.async) +``` + +Pass a fallback when rendering the component: + +```tsx +const User = yield* UserCard.use + +return Loading user...

} /> +``` + +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
{user.name}
+ }, +).pipe( + Async.async, + Async.withOptions({ defaultFallback:

Loading user...

}), +) +``` + +## 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 ( +
+ + {showDetails &&

{user.bio}

} +
+ ) + }, +).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
{user.name}
+ }, +).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
{details.name}
+ }, +).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. diff --git a/packages/docs/docs/getting-started.md b/packages/docs/docs/getting-started.md index 393e36b..dbc8c2d 100644 --- a/packages/docs/docs/getting-started.md +++ b/packages/docs/docs/getting-started.md @@ -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

{user.name}

- }, -).pipe( - Async.async, - Async.withOptions({ defaultFallback:

Loading user...

}), - Memoized.memoized, -) -``` - -Use it from an Effect View parent in the usual way: - -```tsx -const User = yield* UserView.use - -return -``` - -`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 diff --git a/packages/docs/docs/state-management.md b/packages/docs/docs/state-management.md index 19285e8..9d4366d 100644 --- a/packages/docs/docs/state-management.md +++ b/packages/docs/docs/state-management.md @@ -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). diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 7f3da56..b316607 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -6,6 +6,7 @@ const sidebars: SidebarsConfig = { docsSidebar: [ "getting-started", "state-management", + "async", "query", "mutation", "forms", diff --git a/packages/docs/src/pages/index.module.css b/packages/docs/src/pages/index.module.css index 042c345..aee6612 100644 --- a/packages/docs/src/pages/index.module.css +++ b/packages/docs/src/pages/index.module.css @@ -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; diff --git a/packages/docs/src/pages/index.tsx b/packages/docs/src/pages/index.tsx index 7119772..8afa80c 100644 --- a/packages/docs/src/pages/index.tsx +++ b/packages/docs/src/pages/index.tsx @@ -100,8 +100,8 @@ export default function Home(): ReactNode { Effect View for React 19

- React components, - powered by Effect. + React components, + powered by Effect.

Bring typed services, scoped resources, reactive state, server