# remote data

Source: docs/source/pages/docs/remote-data.mdx
URL: /docs/remote-data

# remote data

`atom.io` works especially well with fetched state backed by a type-safe RPC
contract such as [tRPC](https://trpc.io),
[oRPC](https://orpc.dev), or
[Elysia](https://elysiajs.com) + Eden.

Use `Loadable<T>` when a value comes from asynchronous work but should remain in
atom.io's reactive graph. A loadable atom default may be an async function. A
loadable atom family default receives the family key, which keeps query input
and cache identity aligned.

This guide is about query data, fetched data, RPC-backed state, loading flows,
and suspense-like async reads in atom.io.

## compact remote values

For compact remote values, store the typed client result directly:

### compact remote values
Source: docs/source/exhibits/guides/remote-data/compact-remote-values.ts

```ts
import {
	type InferClientErrors,
	type InferClientOutputs,
	ORPCError,
} from "@orpc/client"
import { atom, type Loadable } from "atom.io"

import { client } from "./client.ts"

type Profile = InferClientOutputs<typeof client>[`users`][`profile`]
type ProfileError = InferClientErrors<typeof client>[`users`][`profile`]

const profileAtom = atom<Loadable<Profile>, ProfileError | Error>({
	key: `profile`,
	default: () => client.users.profile(),
	catch: [ORPCError, Error],
})
```

In `catch`, order error constructors from narrow to wide. `atom.io` matches them
in declaration order, so route-specific or domain-specific errors should come
before broad fallbacks such as `Error`.

Guidance:

- Keep remote atom defaults close to the remote contract.
- Do not transform query results heavily inside the default. Use selectors for
  local view shapes.
- Let typed client errors flow through the atom unless the atom intentionally
  translates them into a broader application-domain error.
- Do not add local `try`/`catch` around a query default unless translating the
  error is the point of the atom.
- If the typed client exposes route-specific output and error inference, use it.

## loader functions

When a query returns a broad aggregate but the app renders or edits narrow
pieces independently, normalize the response into smaller atoms.

Use a loader function to:

- derive stable entity keys from server ids
- pre-populate loadable record atom family members
- write editable or frequently read fields into narrower atoms when useful
- return only the acquired keys for the key-list atom's own value

### load remote rows
Source: docs/source/exhibits/guides/remote-data/load-remote-rows.ts

```ts
import { atom, atomFamily, type Loadable, setState } from "atom.io"

import { client, type Row } from "./client.ts"

type RowKey = `row::${string}`

export const rowKeysAtom = atom<Loadable<readonly RowKey[]>, Error>({
	key: `rowKeys`,
	default: async () => {
		const rows = await client.rows.list()
		return loadRows(rows)
	},
	catch: [Error],
})

export const rowAtoms = atomFamily<Loadable<Row>, RowKey, Error>({
	key: `row`,
	default: async (key) => {
		const id = key.slice(`row::`.length)
		const row = await client.rows.get({ id })
		loadRow(row)
		return row
	},
	catch: [Error],
})

function rowKey(id: string): RowKey {
	return `row::${id}`
}

function loadRows(rows: readonly Row[]): readonly RowKey[] {
	return rows.map(loadRow)
}

function loadRow(row: Row): RowKey {
	const key = rowKey(row.id)
	setState(rowAtoms, key, row)
	return key
}
```

Keep aggregate server records close to the server contract. Mirror fields into
narrower atoms only when the app benefits from independent observation or
updates.

## local indices

Use a loadable local index for list screens that need high-quality behavior over
remote data: filters, pagination, sorting, optimistic edits, skeletons, or quick
revisits to already acquired records.

For simple one-shot lists that render exactly what the route returns, prefer a
direct `atom<Loadable<Result[]>>`. Use this pattern when local rows need to keep
participating in the reactive graph after hydration.

### shape

Recommended pieces:

- one typed view atom containing page, page size, sort, search, and filters
- one `Loadable` key-list atom or atom family keyed by the whole view
- loader functions that hydrate local row atom families before returning keys
- an explicit acquired-key index when selectors need to derive from local rows
- selector families that derive visible rows from local atoms
- count or page metadata atoms keyed by the same filters as the row query

The loadable key-list state is the hydration trigger. It does not have to be the
only list source of truth.

In the example below, `rowIndexViewAtom` reads the key-list family member for its
default view, then reads the matching family member again whenever the view changes.
Those reads are deliberate: reading a `Loadable` family member starts the load and gives
the rest of the graph a stable cache entry to observe.

Family keys use [canonical values](/docs/foundations/canonical), so the tuple view is
part of the cache identity by value, not by array object identity. The same page, size,
search, and status tuple finds the same family member. Plain objects are intentionally
not canonical keys: property order and extra runtime properties can make object-shaped
cache keys ambiguous.

### example

### loadable local index
Source: docs/source/exhibits/guides/remote-data/loadable-local-index.ts

```ts
import {
	atom,
	atomFamily,
	getState,
	type Loadable,
	selectorFamily,
	setState,
} from "atom.io"

import { client, type Row, type RowListView } from "./client.ts"

type RowKey = `row::${string}`

type RowIndexView = readonly [
	pageNumber: RowListView[`offset`],
	pageSize: RowListView[`limit`],
	search: RowListView[`search`],
	status: RowListView[`status`],
]

const DEFAULT_ROW_INDEX_VIEW: RowIndexView = [0, 25, ``, null]

export const acquiredRowKeysAtom = atom<readonly RowKey[]>({
	key: `acquiredRowKeys`,
	default: [],
})

export const rowAtoms = atomFamily<Loadable<Row>, RowKey, Error>({
	key: `row`,
	default: async (key) => {
		const id = key.slice(`row::`.length)
		const row = await client.rows.get({ id })
		loadRow(row)
		return row
	},
	catch: [Error],
})

export const rowIndexViewAtom = atom<RowIndexView>({
	key: `rowIndexView`,
	default: () => {
		void getState(rowKeysForViewAtoms, DEFAULT_ROW_INDEX_VIEW)
		return DEFAULT_ROW_INDEX_VIEW
	},
	effects: [
		({ onSet }) => {
			onSet(({ newValue }) => {
				void getState(rowKeysForViewAtoms, newValue)
			})
		},
	],
})

export const rowKeysForViewAtoms = atomFamily<
	Loadable<readonly RowKey[]>,
	RowIndexView,
	Error
>({
	key: `rowKeysForView`,
	default: async ([pageNumber, pageSize, search, status]) => {
		const result = await client.rows.listPage({
			offset: pageNumber * pageSize,
			limit: pageSize,
			search,
			status,
		})
		return loadRows(result.rows)
	},
	catch: [Error],
})

export const visibleRowKeysSelectors = selectorFamily<
	Loadable<readonly RowKey[]>,
	RowIndexView,
	Error
>({
	key: `visibleRowKeys`,
	get:
		(view) =>
		({ get }) => {
			const [pageNumber, pageSize, search, status] = view
			const normalizedSearch = search.trim().toLowerCase()

			const deriveVisibleKeys = () =>
				get(acquiredRowKeysAtom)
					.filter((key) => {
						const row = get(rowAtoms, key)
						if (row instanceof Promise || row instanceof Error) return false
						if (status !== null && row.status !== status) return false
						return (
							normalizedSearch === `` ||
							row.title.toLowerCase().includes(normalizedSearch)
						)
					})
					.toSorted((a, b) => {
						const rowA = get(rowAtoms, a)
						const rowB = get(rowAtoms, b)
						if (
							rowA instanceof Promise ||
							rowB instanceof Promise ||
							rowA instanceof Error ||
							rowB instanceof Error
						) {
							return 0
						}
						return rowB.updatedAt.localeCompare(rowA.updatedAt)
					})
					.slice(pageNumber * pageSize, (pageNumber + 1) * pageSize)

			const keysForView = get(rowKeysForViewAtoms, view)
			return keysForView instanceof Promise
				? keysForView.then(deriveVisibleKeys)
				: deriveVisibleKeys()
		},
	catch: [Error],
})

function rowKey(id: string): RowKey {
	return `row::${id}`
}

function loadRows(rows: readonly Row[]): readonly RowKey[] {
	const keys = rows.map(loadRow)
	setState(acquiredRowKeysAtom, (previousKeys) => {
		const seen = new Set(previousKeys)
		const merged = [...previousKeys]
		for (const key of keys) {
			if (seen.has(key)) continue
			seen.add(key)
			merged.push(key)
		}
		return merged
	})
	return keys
}

function loadRow(row: Row): RowKey {
	const key = rowKey(row.id)
	setState(rowAtoms, key, row)
	return key
}
```

The loadable family returns only the keys for that view. The loader pre-populates
`rowAtoms` and updates `acquiredRowKeysAtom` so selectors can derive richer local
views without re-fetching each row.

### principles

- Key the key-list family by the whole view so every filtered page has a stable
  loadable cache entry.
- Let the UI update the view atom. The UI should not know which row atoms reload
  or how the local index is maintained.
- Derive visible rows from local atoms when the screen needs local edits,
  optimistic behavior, or instant reuse of acquired records.
- Reset the page to `0` when filters or search change. Preserve the current page
  only for changes that do not alter the result set.
- Counts and pagination metadata must use the same filters as the row query.
  Unfiltered counts create phantom pages.
- After a mutation succeeds, deliberately update optimistic local state, then
  reset the smallest affected loadable when the remote owns part of the truth,
  such as ids, timestamps, counts, or normalized row shapes. For a one-row field
  update, reset that row atom. Reset key indexes or query family members only
  when membership, ordering, filters, or counts may have changed.

### refresh and invalidation

`resetState(loadableAtom)` is a reload command. For atom families, the matching form is
`resetState(loadableAtoms, key)`. If the state default is async, resetting immediately
recomputes the default and starts new async work.

Use reset for:

- explicit refresh buttons
- after a mutation when the remote system owns the final result
- retry flows after an error
- test setup

Do not use reset to initialize remote data in a component mount effect. The first read
already initializes the `Loadable`.

When several views need the same remote response, create one `Loadable` atom or atom
family for that response and derive view shapes with selectors. Do not create multiple
`Loadable` defaults for the same endpoint unless they intentionally have different cache
identities.

Read is hydration. Key is identity. Reset is invalidation.

### reset after mutations

Use reset as the resync step after mutations that change remote-owned state.
Optimistic writes can keep the interface responsive, but resetting the affected
row asks the remote for its canonical result.

### reset after mutation
Source: docs/source/exhibits/guides/remote-data/reset-after-mutation.ts

```ts
import { resetState, setState } from "atom.io"

import { client, type RowStatus } from "./client.ts"
import { rowAtoms } from "./load-remote-rows.ts"

type RowKey = `row::${string}`

export async function updateRowStatus(
	id: string,
	status: RowStatus,
): Promise<void> {
	const key: RowKey = `row::${id}`

	setState(rowAtoms, key, async (loadable) => {
		const row = await loadable
		if (row instanceof Error) return row
		return { ...row, status }
	})

	await client.rows.updateStatus({ id, status })

	// Reload this row from the remote, which owns its timestamp and final shape.
	resetState(rowAtoms, key)
}
```

## example contract

The examples above share one tiny in-memory oRPC router and a matching typed
client. Here are the actual living definitions for them.

The server defines the RPC contract and handlers:

### server
Source: docs/source/exhibits/guides/remote-data/server.ts

```ts
import { os } from "@orpc/server"
import { type } from "arktype"

export type Profile = {
	id: string
	displayName: string
	plan: `free` | `pro`
}

export type RowStatus = `open` | `closed`

export type Row = {
	id: string
	title: string
	status: RowStatus
	updatedAt: string
}

export type RowListView = {
	offset: number
	limit: number
	search: string
	status: RowStatus | null
}

const profileData: Profile | undefined = {
	id: `user_01`,
	displayName: `Ada Lovelace`,
	plan: `pro`,
}

let rowData: Row[] = [
	{
		id: `row_01`,
		title: `Repair optimistic row hydration`,
		status: `open`,
		updatedAt: `2026-06-09T18:00:00.000Z`,
	},
	{
		id: `row_02`,
		title: `Ship remote-data docs`,
		status: `closed`,
		updatedAt: `2026-06-08T15:30:00.000Z`,
	},
	{
		id: `row_03`,
		title: `Add ORPC-backed example`,
		status: `open`,
		updatedAt: `2026-06-07T12:45:00.000Z`,
	},
]

const rowQuerySchema = type({
	offset: `number`,
	limit: `number`,
	search: `string`,
	status: `'open' | 'closed' | null`,
})

const rowUpdateStatusSchema = type({
	id: `string`,
	status: `'open' | 'closed'`,
})

export const server = {
	users: {
		profile: os
			.errors({
				NOT_FOUND: {
					data: type({ userId: `string` }),
				},
			})
			.handler(({ errors }) => {
				if (profileData === undefined) {
					throw errors.NOT_FOUND({
						data: { userId: `me` },
					})
				}
				return profileData
			}),
	},
	rows: {
		list: os.handler(() => {
			return rowData
		}),
		updateStatus: os.input(rowUpdateStatusSchema).handler(({ input }) => {
			rowData = rowData.map((row) => {
				if (row.id !== input.id) return row
				return {
					...row,
					status: input.status,
					updatedAt: new Date().toISOString(),
				}
			})
			return {
				success: true,
			}
		}),
		listPage: os.input(rowQuerySchema).handler(({ input }) => {
			const search = input.search.trim().toLowerCase()
			const filteredRows = rowData.filter((row) => {
				if (input.status !== null && row.status !== input.status) return false
				return search === `` || row.title.toLowerCase().includes(search)
			})
			return {
				rows: filteredRows.slice(input.offset, input.offset + input.limit),
				total: filteredRows.length,
			}
		}),
		get: os
			.input(type({ id: `string` }))
			.errors({
				NOT_FOUND: {
					data: type({ id: `string` }),
				},
			})
			.handler(({ errors, input }) => {
				const row = rowData.find((candidate) => candidate.id === input.id)
				if (row === undefined) {
					throw errors.NOT_FOUND({
						data: { id: input.id },
					})
				}
				return row
			}),
	},
}
```

The client derives a typed client directly from that router:

### client
Source: docs/source/exhibits/guides/remote-data/client.ts

```ts
import { createRouterClient } from "@orpc/server"

import { server } from "./server.ts"

export const client = createRouterClient(server)

export type { Profile, Row, RowListView, RowStatus } from "./server.ts"
```

In a real app, this client would usually be created from your transport layer.
For these docs, a server-side client keeps the example compact while still proving the
contract, outputs, and errors are all wired together.
