<!--
Sitemap:
- [rxfy](/index): Typed, normalized, reactive state — built on RxJS
- [Comparison](/comparison): rxfy versus Redux Toolkit, MobX, Jotai, and TanStack Query
- [Agent Skills](/agent-skills): Accurate rxfy context for AI coding assistants
- [Examples](/examples): Runnable apps, from client-only to fully live
- [Changelog](/changelog)
- [Getting Started](/getting-started)
- [Store quickstart](/getting-started/store): Normalized reactive state in a client-only app
- [Framework quickstart](/getting-started/framework): The full stack for a live app
- [Core Concepts](/core-concepts): The ideas rxfy is built on
- [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
- [Live client](/react/live-client): Wire a WebSocket transport into StoreProvider
- [rxfy-server](/framework/server): Bind Drizzle tables, write, and publish live updates
- [defineResource](/framework/server/define-resource): Tie a Drizzle table to an rxfy model
- [createServer](/framework/server/create-server): Wire db, resources, hub, and keyer into a Live object
- [Writes](/framework/server/writes): live.create / live.update / live.delete and touch
- [Live messages](/framework/server/messages): What travels between server and client
- [Grants & live hydration](/framework/server/grants): Hand off an SSR render to a live client
- [createTopicKeyer](/framework/server/create-topic-keyer): Time-windowed HMAC ids for topics
- [createInMemoryHub](/framework/server/hub): The pub/sub backbone
- [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
- [Build a Todo app](/guides/todo-app): A guided, end-to-end first app
- [Pagination and infinite scroll](/guides/pagination): Load and append pages into one normalized list
- [Live blog](/guides/live-blog): Build a real-time blog: SSR, then live patches and stale badges
-->

# Build a Todo app \[A guided, end-to-end first app]

This lesson takes you from an empty project to a working, interactive todo app: a
list you can add to, toggle, and edit, with every view of a todo staying in sync. You
will write a model, a state, and three small components. Follow the steps in order and
paste each block where it says; by the end the app runs.

No backend is required. The "fetch" is an in-memory array so you can run everything
locally, and the same `fetchFn` signature works against a real API later.

:::tip
Want the API details behind a step? Each links to its reference page. Read those
after the lesson; for now, build.
:::

## Before you start

You need a React 18+ app with TypeScript (a fresh Vite `react-ts` project works). By the
end you will have created these files:

:::file-tree

* src
  * main.tsx
  * todos.ts
  * App.tsx
  * TodoItem.tsx
  * EditTitle.tsx
    :::

Install the packages and their peers:

:::code-group

```bash [npm]
npm install rxfy rxfy-react
npm install rxjs zod lodash
```

```bash [pnpm]
pnpm add rxfy rxfy-react
pnpm add rxjs zod lodash
```

```bash [yarn]
yarn add rxfy rxfy-react
yarn add rxjs zod lodash
```

```bash [bun]
bun add rxfy rxfy-react
bun add rxjs zod lodash
```

:::

::::steps

### Wrap the app in `StoreProvider`

`StoreProvider` holds the store that the hooks read and write. Wrap your root once:

```tsx [main.tsx]
import { createRoot } from "react-dom/client";
import { StoreProvider } from "rxfy-react";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StoreProvider>
    <App />
  </StoreProvider>,
);
```

### Define a model

A [model](/rxfy/create-model) is an entity type plus how to read its id. Each todo
gets stored once under that id, so every component renders the same copy.

```ts [todos.ts]
import { z } from "zod";
import { createModel } from "rxfy";

export const TodoModel = createModel({
  schema: z.object({ id: z.string(), title: z.string(), done: z.boolean() }),
  getKey: (todo) => todo.id,
  name: "todo",
});

export type Todo = z.infer<typeof TodoModel.schema>;
```

### Define a state and a fake fetch

A [state](/rxfy/define-state) declares the params it takes, the model fields it
returns, and the mutations that change it. Add three mutations now: add a todo, toggle
`done`, and save an edited todo (Step 6 uses the last one):

```ts [todos.ts (continued)]
import { defineState, array } from "rxfy";

export const todosState = defineState({
  key: "todos",
  params: z.object({ filter: z.enum(["all", "active", "done"]) }),
  model: { todos: array(TodoModel) },
  mutations: {
    addTodo: (prev, title: string) => ({
      ...prev,
      todos: [...prev.todos, { id: crypto.randomUUID(), title, done: false }],
    }),
    toggleTodo: (prev, id: string) => ({
      ...prev,
      todos: prev.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    }),
    updateTodo: (prev, next: Todo) => ({
      ...prev,
      todos: prev.todos.map((t) => (t.id === next.id ? next : t)),
    }),
  },
});
```

Reducers work with whole todos, not ids; rxfy normalizes the result back into the store
for you.

Now the fetch. It returns the full shape (`{ todos: Todo[] }`) and respects the `filter`
param. Swap this for a real `fetch()` later; the signature is the same:

```ts [todos.ts (continued)]
const seed: Todo[] = [
  { id: "1", title: "Learn rxfy", done: false },
  { id: "2", title: "Build a todo app", done: false },
];

export async function fetchTodos({ filter }: { filter: "all" | "active" | "done" }) {
  const todos = seed.filter((t) => (filter === "all" ? true : filter === "done" ? t.done : !t.done));
  return { todos };
}
```

### Render the list

`useStateData` runs the fetch, normalizes the todos, and hands back `data$`, the list of
todo **ids**, not the todos themselves. You render the ids and let each row read its own
todo (Step 5). `Pending` shows a fallback until the data arrives.

```tsx [App.tsx]
import { useMemo, useState } from "react";
import { useStateData, Pending } from "rxfy-react";
import { todosState, fetchTodos } from "./todos";
import { TodoItem } from "./TodoItem";

export function App() {
  const [filter] = useState<"all" | "active" | "done">("all");
  const params = useMemo(() => ({ filter }), [filter]);

  const { data$, mutations } = useStateData({ state: todosState, fetchFn: fetchTodos, params });

  return (
    <Pending value$={data$} pending={<p>Loading…</p>}>
      {({ todos }) => (
        <>
          <ul>
            {todos.map((id) => (
              <TodoItem key={id} id={id} onToggle={() => mutations.toggleTodo(id)} />
            ))}
          </ul>
          <button onClick={() => mutations.addTodo("New todo")}>Add</button>
        </>
      )}
    </Pending>
  );
}
```

Two things are wired here already: `mutations.addTodo` appends a todo and the list
re-renders with its new id, and `mutations.toggleTodo` flips `done` on one todo. Both
update the store in place, no re-fetch. (`updateTodo` gets wired in Step 6.)

:::note
`useStateData` compares `params` by value, so the fetch restarts when `filter` changes,
not when the object identity does. The `useMemo` is a habit worth keeping anyway:
[`useStatePagedData`](/react/use-state-paged-data) does key its list off params identity.
:::

### Render one todo

Each row reads its own todo from the store by id. Because the row subscribes to that one
entity, it re-renders when (and only when) that todo changes:

```tsx [TodoItem.tsx]
import { useMemo } from "react";
import { useModelStore, Pending } from "rxfy-react";
import { TodoModel } from "./todos";

export function TodoItem({ id, onToggle }: { id: string; onToggle: () => void }) {
  const store = useModelStore(TodoModel);
  const todo$ = useMemo(() => store.get(id), [store, id]);

  return (
    <Pending value$={todo$}>
      {(todo) => (
        <li>
          <input type="checkbox" checked={todo.done} onChange={onToggle} />
          {todo.title}
        </li>
      )}
    </Pending>
  );
}
```

Run the app. You should see two todos, an **Add** button that appends a third, and
checkboxes that toggle. Adding or toggling updates the list with no spinner; the store
already holds the data.

### Edit a title with a form, save on submit

Last step: edit a todo through a form that keeps its changes local until you submit. A form
wants a draft the user can type into (and discard) without touching the rest of the app;
you persist it only on **Save**.

Hold that draft in a local [`Atom`](/rxfy/create-atom) seeded from the todo, focus the
field you're editing with a [`Lens`](/rxfy/create-lens), and bind it with
[`useAtom`](/react/use-atom). The form never reads or writes the store;
on submit it hands the edited todo to an `onSave` callback, and the parent does the write.

```tsx [EditTitle.tsx]
import { useMemo } from "react";
import { createAtom, createLens, keyLens } from "rxfy";
import { useAtom } from "rxfy-react";
import { Todo } from "./todos";

export function EditTitle({ todo, onSave }: { todo: Todo; onSave: (todo: Todo) => void }) {
  const draft$ = useMemo(() => createAtom(todo), [todo]);
  const title$ = useMemo(() => createLens(draft$, keyLens("title")), [draft$]);
  const [title, setTitle] = useAtom(title$);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSave(draft$.get());
      }}
    >
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit">Save</button>
    </form>
  );
}
```

`draft$` is a plain `Atom<Todo>` that lives only in this component; `title$` is a `Lens`
onto its `title` field, and `useAtom` binds that field to the input. Typing updates the
draft, not the store; the list row keeps showing the saved title until you submit.

Now render the form in `TodoItem` and let it report saves upward:

```tsx [TodoItem.tsx]
// render <EditTitle> instead of the static title
import { EditTitle } from "./EditTitle";
import { Todo } from "./todos";

export function TodoItem({
  id,
  onToggle,
  onRename,
}: {
  id: string;
  onToggle: () => void;
  onRename: (todo: Todo) => void;
}) {
  // ...store and todo$ as before...
  return (
    <Pending value$={todo$}>
      {(todo) => (
        <li>
          <input type="checkbox" checked={todo.done} onChange={onToggle} />
          <EditTitle todo={todo} onSave={onRename} />
        </li>
      )}
    </Pending>
  );
}
```

Finally, wire `onRename` to the write in `App`; `mutations.updateTodo` is the save:

```tsx [App.tsx]
// pass onRename alongside onToggle
<TodoItem key={id} id={id} onToggle={() => mutations.toggleTodo(id)} onRename={mutations.updateTodo} />
```

Type a new title and press **Save**. The edit doesn't show until you submit; then
`updateTodo` replaces the todo in the store and every view of it re-renders, no re-fetch.
`onSave` is your write boundary: here it runs a mutation, but against a real backend this is
where you'd `await` a `PATCH` before (or after) updating the store.

::::

## What you built

* A **model** that stores each todo once, keyed by id.
* A **state** that fetches and mutates todos without manual store bookkeeping.
* Components that render by id, so an edit reaches every view of a todo with no re-fetch.

## Where to go next

* [Normalization](/core-concepts/normalization): why `data$` emits ids, and what it buys you.
* [Model](/rxfy/create-model), [State](/rxfy/define-state), and [React Bindings](/react): the full API.
* [Atom](/rxfy/create-atom) and [Lens](/rxfy/create-lens): the draft-and-save form here, in depth.
* [Live blog](/guides/live-blog) and [Pagination](/guides/pagination): common next tasks.
