<!--
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
-->

# defineState \[Typed, normalized state shapes with fetching and mutations]

`defineState` declares a typed, normalized state shape: fetch params, model fields, and
optional mutations. The result wires automatically into the
[`createModel`](/rxfy/create-model) stores managed by `StoreProvider`.

```ts
import { z } from "zod";
import { defineState, array } from "rxfy";

const todosState = defineState({
  key: "todos", // stable identity for the SSR query cache and the sync invalidation channel
  params: z.object({ filter: z.enum(["all", "active", "done"]) }),
  model: { todos: array(TodoModel) },
  mutations: {
    addTodo: (prev, todo: { id: string; title: string; done: boolean }) => ({
      ...prev,
      todos: [...prev.todos, todo],
    }),
  },
});
```

A `StateDescriptor<TParams, TShape, TMutations>` exposes (note your `params` is stored as
`paramsSchema` and your `model` as `fields`):

```ts
type StateDescriptor<TParams, TShape, TMutations> = {
  key: string; // SSR query-cache identity, also derives the sync invalidation channel
  window?: readonly (keyof TParams & string)[]; // param names that slice within a dataset (page, cursor, sort)
  paramsSchema: z.ZodType<TParams>; // the `params` schema, used to validate fetch params
  fields: FieldsMap; // the `model` entries: array(Model) | single(Model) | a zod schema (plain value)
  mutations: TMutations; // the reducers, bound by useStateData
};
```

It's a plain, serializable description — it holds no data and runs no fetch itself.
[`useStateData`](/react/use-state-data) consumes it: it validates params with `paramsSchema`,
normalizes a fetch result through `fields`, and binds `mutations`.

Mutation reducers operate on the full fetch shape (entities). When invoked through
`useStateData`, rxfy denormalizes the current ids into fresh entities, runs the reducer,
and normalizes the result back into model stores and ids. The normalized query shape
(what `data$` emits) is derived from the field map: array fields become `string[]`
(entity keys), single fields become `string`, and [plain value fields](#plain-value-fields)
pass through unchanged with their real type.

## `array` / `single`

Field descriptor helpers that declare whether a `defineState` model field holds an array or
a single item.

```ts
import { array, single } from "rxfy";

const userPageState = defineState({
  key: "user-page",
  params: z.object({ userId: z.string() }),
  model: {
    user: single(UserModel), // one item
    friends: array(UserModel), // array of items
  },
});
```

## Relations & per-state joins

When a model declares a [relation](/rxfy/create-model#relations-between-models) (`ref`/`refArray`),
each state decides **per fetch** whether to deliver that relation joined or as a bare id. Chain
`.with({ … })` on an `array()`/`single()` field to join it:

```ts
import { single, array } from "rxfy";

// Post page — joins the category. The payload carries the full category object; it normalizes
// into the Category store, and `post.category` is a resolvable StoreKey in `data$`.
const postState = defineState({
  key: "post",
  params: z.object({ id: z.string() }),
  model: { post: single(PostModel).with({ category: true }) },
});

// List page — no join. The category isn't fetched, so `post.category` is absent from the type;
// only the plain `categoryId` is present. You can't accidentally resolve a category you never loaded.
const postsState = defineState({
  key: "posts",
  params: z.object({}),
  model: { posts: array(PostModel) },
});
```

The same model and store back both pages — the list stores refs only, and opening the post page
joins the category into the *same* store. `.with()` is a type-safe map over the model's relations:
its keys autocomplete to the relation fields, and an unknown key is a type error.

### Nested joins (recursive)

Nesting mirrors Prisma's `include`. A relation's value is either `true` (join it flat) or a **nested
map** that joins *its* relations too — to any depth. The nested keys autocomplete against the
**nested model's** relations, so the whole tree is checked at compile time (no `join()` helper):

```ts
// A post → its category → and that category's author, in one fetch:
const postState = defineState({
  key: "post",
  params: z.object({ id: z.string() }),
  model: {
    post: single(PostModel).with({
      category: { author: true }, // join `category`, and inside it join `category.author`
    }),
  },
});
```

Every level normalizes into its own store, and `data$` reflects exactly the tree you joined — each
joined relation is a resolvable `StoreKey`, and a relation you didn't join is absent from the type at
that level. So reads walk the tree store by store, and you can only reach what the state fetched:

```tsx
const [post] = useAtom(useModelStore(PostModel).get(postId));
const [category] = useAtom(useModelStore(CategoryModel).get(post.category)); // joined → a StoreKey
const [author] = useAtom(useModelStore(AuthorModel).get(category.author)); // nested join → a StoreKey
```

These reads need no `!`. `get` carries the key's brand through to its result, so an id minted by a
joined state resolves to a **view** whose joined relations are *required* — `post.category` and
`category.author` are guaranteed by the type at every level you joined, not just at runtime. Thread
those branded ids down to child components (rather than plain strings) and each `get` returns the
matching view. See [view-typed reads](/react/use-model-store#view-typed-reads).

A to-many relation nests the same way — `refArray` joins compose with `{ tags: { … } }`, and the
join maps straight onto your ORM's include (e.g. Drizzle `with: { category: { with: { author: true } } }`),
so the query result drops into `sync.serve` unchanged. See
[create-model relations](/rxfy/create-model#relations-between-models).

## Inferring a state's types

A `StateDescriptor` carries its shapes as phantom type slots. Rather than reach into them, pull any
one off a `defineState` value with these helpers — the `z.infer` of a state:

| Helper            | The shape it extracts                                                                                       |
| ----------------- | ----------------------------------------------------------------------------------------------------------- |
| `ParamsOf<S>`     | the fetch params (the `params` schema's output)                                                             |
| `NormalizedOf<S>` | the normalized query shape `data$`/[`<Pending>`](/react/pending) emit — model fields hold ids (`StoreKey`s) |
| `ShapeOf<S>`      | the denormalized output — entities inline, relations as keys (the client transport shape)                   |
| `InputOf<S>`      | the denormalized input a raw fetch / `sync.serve` payload has before parsing — relations as nested entities |
| `WritableOf<S>`   | the writable query shape [`setRaw`](/react/use-state-data) accepts — each slot takes an id or an entity     |

```ts
import type { NormalizedOf, InputOf } from "rxfy";

type PostQuery = NormalizedOf<typeof postState>; // { post: StoreKey<…> } — what data$ emits
type ServerPayload = InputOf<typeof postState>; // { post: { …, category: {…} } } — what sync.serve takes
```

A joined query shape brands each id with the **view** it was fetched as (its joined relations
required). Deref that brand with `ViewOf` to name a nested ref type — the id you thread into a child
component — without repeating a conditional:

```ts
import type { NormalizedOf, ViewOf } from "rxfy";

type PostRef = NormalizedOf<typeof postState>["post"]; // StoreKey<PostView>
type CategoryRef = ViewOf<PostRef>["category"]; // StoreKey<CategoryView> — the joined relation, required
```

## Plain value fields

Not every field is a normalized entity. A `model` entry can also be a **bare zod schema** — a
boolean, a primitive, or a plain object — declared inline alongside `array()` / `single()`. These
**plain value fields** are not stored in any [Model](/rxfy/create-model) store: they live in the
query state and pass straight through `data$` with their real value and type, while entity fields
still resolve to ids.

```ts
import { z } from "zod";
import { defineState, array } from "rxfy";

const dashboardState = defineState({
  key: "dashboard",
  params: z.object({ id: z.string() }),
  model: {
    todos: array(TodoModel), // normalized → string[] (ids)
    isOpen: z.boolean(), // plain → boolean (passed through)
    filters: z.object({ q: z.string(), tab: z.enum(["all", "mine"]) }), // plain → { q, tab }
  },
  mutations: {
    setOpen: (prev, open: boolean) => ({ ...prev, isOpen: open }),
  },
});
```

`data$` for this state emits `{ todos: string[]; isOpen: boolean; filters: { q; tab } }` — entity
fields as ids, plain fields as their value. The distinction is structural: an `array()`/`single()`
descriptor carries a model, anything else is treated as a plain value, so a plain object that happens
to contain an `id` is never mistaken for an entity. Plain values are validated against their schema
in development and passed through untouched in production.

:::note
Plain values ride inside the query's value, so a **keyed** state dehydrates them for
[SSR](/core-concepts/ssr) along with its entity ids — keep them JSON-serializable, the same constraint entities
have.
:::

## `window`

For synced apps, `window` names the params that slice *within* a dataset — page, cursor, sort —
as opposed to params that select *which* dataset the state shows. Windowed params are excluded
from the state's sync invalidation channel, so every page of a list shares one channel and a
server write marks all of them stale at once.

```ts
const postsState = defineState({
  key: "posts",
  params: z.object({ userId: z.string(), page: z.number() }),
  window: ["page"], // pages share one invalidation channel per userId
  model: { posts: array(PostModel) },
});
```

The field is inert in a client-only store — it only affects channel derivation in the
real-time layer. See [Grants](/framework/server/grants) for how the channel is derived
and signed into a grant.
