<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, TanStack Query, and TanStack DB
- [Inspired by](/inspired-by): the libraries and ideas rxfy grew out of
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully synced
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Create Store](/getting-started/create-store): Normalized reactive state in a client-only app
- [Add SSR](/getting-started/add-ssr): Render the first paint on the server, hydrate with no refetch
- [Add Sync Client](/getting-started/add-sync-client): The full stack: the server publishes, the client syncs
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [Observables](/core-concepts/observables): A value that changes over time, that you can subscribe to
- [Normalization](/core-concepts/normalization): Store each entity once, reference it by id
- [Late Unwrapping](/core-concepts/late-unwrapping): Unwrap async state at the leaf, not the trunk
- [Server-Side Rendering](/core-concepts/ssr): Dehydrate on the server, hydrate with no refetch
- [rxfy](/rxfy): The core package: atoms, lenses, models, and states
- [createModel](/rxfy/create-model): Typed entities in normalized storage
- [defineState](/rxfy/define-state): Typed, normalized state shapes with fetching and mutations
- [createAtom](/rxfy/create-atom): A reactive cell with synchronous get, set, and modify
- [createLens](/rxfy/create-lens): A two-way view into part of an Atom
- [React Bindings](/react): Hooks and helpers for using rxfy in React
- [useStateData](/react/use-state-data): Fetch, normalize, and subscribe to a query
- [useStatePagedData](/react/use-state-paged-data): Paginated and infinite-scroll lists
- [useModelStore](/react/use-model-store): Subscribe to one normalized entity by id
- [useAtom](/react/use-atom): Two-way binding for any IAtom
- [Pending](/react/pending): Render pending, rejected, and fulfilled UI for any observable
- [usePending](/react/use-pending): The status value behind Pending
- [useObservable](/react/use-observable): Bind a raw Observable to React
- [Sync Client in React](/react/sync-client): StoreProvider, useSyncClient, and update handles
- [rxfy-client](/framework/client): The framework-agnostic browser sync runtime
- [createSyncClient](/framework/client/create-sync-client): Connect a transport and drive the sync loop
- [readSsrGrants](/framework/client/read-ssr-grants): Lift SSR-embedded channel grants
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish sync updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createSync](/framework/server/create-server): Wire a storage adapter, hub, and secret into a Live object
- [createInMemoryHub](/framework/server/hub): The socket-keyed pub/sub backbone
- [Writes](/framework/server/writes): sync.create / sync.update / sync.delete and touch
- [Storage adapters](/framework/server/storage-adapters): Persist writes with Drizzle or in memory
- [Sync messages](/framework/server/messages): What travels between server and client
- [Grants](/framework/server/grants): The server signs what it serves; the client subscribes with the token
- [rxfy-ws](/framework/ws): The default WebSocket transport
- [createWsServer](/framework/ws/server): Attach a Hub to WebSocket connections
- [createWsClient](/framework/ws/client): The browser transport with reconnect and replay
- [Custom transports](/framework/ws/custom-transport): Bring your own ClientTransport
- [Guides](/guides): Task-focused walkthroughs of common rxfy patterns
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
-->

# Late Unwrapping \[Unwrap async state at the leaf, not the trunk]

One of rxfy's [core principles](/) is that values unwrap **as late as possible**. Async data
travels through your app still wrapped in its loading status, and only the leaf that actually
renders a value unwraps it.

:::note\[Mental model]
Async state is a value-over-time that carries its own status · intermediate code passes the
wrapper along untouched · a single leaf unwraps it, right where the value is shown.
:::

The APIs below (`useStateData`, `Pending`, `usePending`, `store.get`/`store.set`) are covered in
[State](/rxfy/define-state) and [React Bindings](/react); they appear here only to make the idea
concrete.

## The problem: unwrapping too early

The reflex in most apps is to unwrap async state at the top of a component and branch on it:

```tsx
function Dashboard() {
  const { data, isLoading, error } = useQuery(/* … */);

  if (isLoading) return <Spinner />; // ← everything below waits on this one flag
  if (error) return <Error error={error} />;

  return (
    <Layout>
      <Header user={data.user} />
      <PostList posts={data.posts} />
      <Sidebar stats={data.stats} />
    </Layout>
  );
}
```

Three costs follow from unwrapping at the trunk:

* **One flag gates a whole subtree.** The header, the list, and the sidebar all disappear behind
  a single spinner, even though they could render independently.
* **Every consumer re-renders when it flips.** `isLoading` going `true → false` re-renders the
  entire component, not just the parts whose data changed.
* **Writes get stuck behind reads.** With the value locked inside `data`, code that wants to
  *write* an entity has to wait for the read to resolve and thread the update back through it.

## Wrapped values travel; leaves unwrap

In rxfy, async data does not arrive as `{ data, isLoading, error }`. It arrives as an
`Observable` that carries an `IWrapped<T>` — the discriminated union
`IDLE | PENDING | FULFILLED | REJECTED` (`StatusEnum`). The canonical source is `data$` from
[`useStateData`](/react/use-state-data) — a state's query result (a shape of ids).

Intermediate code never inspects the status. It subscribes, composes, and passes the wrapper
along. The status rides inside the value all the way down to the component that renders it.

Entity reads are *not* async sources: an id in a fulfilled query means its entity is already
in the [model store](/react/use-model-store), so `store.get(id)` is a synchronous handle.
The query is the only thing that loads; entities referenced by it are simply present.

## `Pending`: unwrap at the edge

The leaf that turns a value into UI is the one — and only one — that unwraps. In React that is
[`Pending`](/react/pending): give it the wrapped observable and a render prop per status.

```tsx
import { Pending, useStateData } from "rxfy-react";
import { postsState, fetchPosts } from "./posts";

function PostList({ params }: { params: { page: number } }) {
  const { data$, reload } = useStateData({ state: postsState, fetchFn: fetchPosts, params });

  return (
    <Pending
      value$={data$}
      pending={<p>Loading…</p>}
      rejected={({ error }) => (
        <p>
          Error: {String(error)} <button onClick={reload}>Retry</button>
        </p>
      )}
    >
      {({ posts }) => (
        <ul>
          {posts.map((id) => (
            <PostRow key={id} id={id} />
          ))}
        </ul>
      )}
    </Pending>
  );
}
```

`PostRow` then subscribes to *its own* entity, again at the leaf — the id came from the
fulfilled query, so the read is synchronous, and a single post changing re-renders that row
alone, not the list:

```tsx
import { asKey } from "rxfy";
import { useAtom, useModelStore } from "rxfy-react";
import { PostModel } from "./models";

function PostRow({ id }: { id: string }) {
  const store = useModelStore(PostModel);
  const [post] = useAtom(store.get(asKey(PostModel, id)));
  return <li>{post.title}</li>;
}
```

**Prefer `Pending` over the [`usePending`](/react/use-pending) hook.** The component keeps the
unwrap at the render edge — the value stays wrapped right up to the boundary that shows it, and the
surrounding component never branches on status. Reaching for `usePending` pulls that branch up into
the component body, which is an *earlier* unwrap; keep it for the rare case where you need the
`IWrapped<T>` value itself (a count, a derived label) rather than to gate a subtree. Either way the
unwrap stays as narrow as the thing being shown, and below it entity reads carry no status at all.

Late unwrapping also buys smooth revalidation for free. A reload refetches **in place**: `data$`
keeps its identity and never emits the interim `PENDING`, so `Pending` holds the last fulfilled
value across a background refresh instead of flashing its pending UI. Unwrap at the edge and a
live `stale` refetch updates the content underneath without tearing the tree down.

## Writes never unwrap at all

A write does not participate in the status lifecycle. `store.set(id, entity)` fills a slot
whether or not any query over it has settled:

```ts
const store = useModelStore(PostModel);

// Lands immediately — no "wait for the list to load, then patch it in".
store.set(post.id, post);
```

Because entities live in one [normalized](/core-concepts/normalization) store keyed by id, a write
reaches every subscriber of that id on its own. A websocket patch, an optimistic update, and a
freshly fetched row all take the same path — none of them has to find and unwrap a read first.

## Why it matters

Re-render scope tracks unwrap scope. Unwrap at the trunk and the whole subtree re-renders on
every status change; unwrap at the leaf and only that leaf does. The narrower the unwrap:

* the narrower the re-render — a single entity updating touches a single component;
* the more independent the UI — each region shows its own pending and error state instead of one
  spinner for everything;
* the simpler writes stay — they target a slot by id and never wait on a read.

Keeping values wrapped until the last moment is what lets loading and error UI live exactly where
the data is used, and lets writes move independently of reads.
