> ## Documentation Index
> Fetch the complete documentation index at: https://confect.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Foldkit

> Use Confect's Foldkit bindings to call your functions from a Foldkit app.

`@confect/foldkit` bridges Confect into [Foldkit](https://foldkit.dev), The Elm
Architecture in TypeScript powered by Effect. It maps Confect's client surface
onto Foldkit's three integration seams: an application-scoped `Client` becomes
a [resource](https://foldkit.dev/core/resources), reactive queries become
[Subscription](https://foldkit.dev/core/subscriptions) entries, and queries,
mutations, and actions become
[Commands](https://foldkit.dev/core/commands). Args and return values are
encoded and decoded through the [Effect
Schemas](/v10/concepts/schema-restrictions) in your [function
specs](/v10/concepts/spec-impl-model), and every failure is folded into a Message,
so Command and Subscription error channels stay `never` as Foldkit requires.

Functions are referenced via `refs` (from `confect/_generated/refs`) instead
of Convex's `api` object, the same as with the other Confect clients. The
bindings work with Confect functions only—refs for [plain Convex
functions](/v10/server/plain-convex-functions) carry no schemas, so they aren't
accepted.

## Installation

```bash theme={null}
pnpm add @confect/foldkit @confect/js @confect/core foldkit
```

<Note>
  Foldkit pins an exact `effect` version in its `peerDependencies`. When
  Confect tracks a newer Effect release than Foldkit does, tell your package
  manager to accept the mismatch—with pnpm, in `pnpm-workspace.yaml`:

  ```yaml theme={null}
  peerDependencyRules:
    allowedVersions:
      "foldkit>effect": "4"
  ```

  With npm, use an [`overrides`](https://docs.npmjs.com/cli/configuring-npm/package-json#overrides)
  entry that pins `effect` to the version your app uses.
</Note>

## Setup

Pass the `Client` layer to your application's `resources`. The client combines
the WebSocket API with the pagination-session allocator, lives for the lifetime
of the app, and closes the WebSocket at teardown.

```ts src/entry.ts theme={null}
import * as Confect from "@confect/foldkit";
import { Runtime } from "foldkit";

const application = Runtime.makeApplication({
  Model,
  init,
  update,
  view,
  subscriptions,
  resources: Confect.Client.layer(import.meta.env.VITE_CONVEX_URL),
  container: document.getElementById("root"),
});

Runtime.run(application);
```

Commands and subscription entries built by `@confect/foldkit` require the
`Confect.Client.Client` service, which this layer satisfies.

## Reactive queries as Subscriptions

`Subscription.reactiveQuery` builds a complete Foldkit subscription entry for
a query ref. Its dependencies are the query args wrapped in an `Option`
extracted from your Model: `None` closes the subscription, a change from one
`Some` to another resubscribes with the new args, and structurally equal args
leave the subscription running (dependency equivalence is derived from the
ref's `args` schema).

```ts src/subscription.ts theme={null}
import * as Confect from "@confect/foldkit";
import * as Option from "effect/Option";
import * as Subscription from "foldkit/subscription";

import refs from "../confect/_generated/refs";
import {
  FailedGetNote,
  SucceededGetNote,
  type Message,
  type Model,
} from "./model";

export const subscriptions = Subscription.make<
  Model,
  Message,
  Confect.Client.Client
>()(() => ({
  note: Confect.Subscription.reactiveQuery<Model>()(refs.public.notes.get, {
    args: (model) => Option.map(model.selectedNoteId, (noteId) => ({ noteId })),
    onSuccess: (note) => SucceededGetNote({ note }),
    onError: (error) => FailedGetNote({ message: String(error) }),
  }),
}));
```

`onSuccess` receives the decoded return value on every server update;
`onError` receives the ref's decoded typed error (see [Error
Handling](/v10/server/error-handling)), a transport-level `WebSocketClientError`,
or a `SchemaError`. Errors are emitted as Messages without ending the reactive
query: Convex handles retryable infrastructure failures itself, and the same
subscription can produce a later value if its query result recovers.

Queries without args may omit the `args` extractor, which leaves the
subscription always open:

```ts theme={null}
Confect.Subscription.reactiveQuery<Model>()(refs.public.notes.list, {
  onSuccess: (notes) => SucceededGetNotes({ notes }),
  onError: (error) => FailedGetNotes({ message: String(error) }),
});
```

### Custom entries

When an entry needs extra dependencies or custom gating, write it by hand and
use `Subscription.reactiveQueryStream` as its `dependenciesToStream` body:

```ts theme={null}
import * as Schema from "effect/Schema";

Subscription.make<Model, Message, Confect.Client.Client>()((entry) => ({
  note: entry(
    { noteId: Schema.Option(Schema.String) },
    {
      modelToDependencies: (model) => ({ noteId: model.selectedNoteId }),
      dependenciesToStream: ({ noteId }) =>
        Option.match(noteId, {
          onNone: () => Stream.empty,
          onSome: (noteId) =>
            Confect.Subscription.reactiveQueryStream(refs.public.notes.get, {
              onSuccess: (note) => SucceededGetNote({ note }),
              onError: (error) => FailedGetNote({ message: String(error) }),
            })({ noteId }),
        }),
    },
  ),
}));
```

## Commands

`Command.query`, `Command.mutation`, and `Command.action` build Foldkit
Command definitions whose Command args are the ref's args. `messages`
declares the Messages the Command can produce—the same declaration
Foldkit's own `Command.define` takes—and the handlers must produce
instances of them. Call the definition from `update` to construct a Command
instance—nothing runs until the Foldkit runtime executes it. Add the handler
to your exhaustive update Match with `Match.tag`:

```ts src/command.ts theme={null}
import * as Confect from "@confect/foldkit";

import refs from "../confect/_generated/refs";
import { FailedSaveNote, SucceededSaveNote } from "./model";

export const SaveNote = Confect.Command.mutation(
  "SaveNote",
  refs.public.notes.insert,
  {
    messages: [SucceededSaveNote, FailedSaveNote],
    onSuccess: (noteId) => SucceededSaveNote({ noteId }),
    onError: (error) => FailedSaveNote({ message: String(error) }),
  },
);
```

```ts src/update.ts theme={null}
Match.tag("ClickedSave", () => [model, [SaveNote({ text: model.draft })]]),
```

Follow Foldkit's [naming convention](https://foldkit.dev/core/commands):
Commands are verb-first imperatives, result Messages are past-tense facts
(`SucceededSaveNote`/`FailedSaveNote`). `onError` receives the same error
union as subscriptions—`Confect.Command.Error<typeof ref>` names it when you
want to factor a handler out.

### Interruption

Pass `interrupt` to make a factory-built Command
[interruptible](https://foldkit.dev/core/commands). The returned definition
gains an `Interrupt` constructor that builds an ordinary Command: it stops
every in-flight invocation and results in `toMessage(outcome)`, where the
outcome is `Interrupted` (at least one invocation was stopped—its result
Messages are guaranteed never to dispatch) or `NotFound` (nothing was in
flight).

`interrupt: true` keys every invocation by the Command name—right when at
most one invocation is meaningfully in flight:

```ts theme={null}
export const SaveDraft = Confect.Command.mutation(
  "SaveDraft",
  refs.public.notes.insert,
  {
    messages: [SucceededSaveDraft, FailedSaveDraft],
    onSuccess: (noteId) => SucceededSaveDraft({ noteId }),
    onError: (error) => FailedSaveDraft({ message: String(error) }),
    interrupt: true,
  },
);
```

Build the interrupt Command from its `Match.tag` update handler:

```ts theme={null}
Match.tag("ClickedCancel", () => [
    model,
    [SaveDraft.Interrupt((outcome) => CompletedCancelSaveDraft({ outcome }))],
  ]),
```

`interrupt: { keyFields, toKey }` derives the key from the ref's args, so
concurrent invocations can be interrupted independently. `Interrupt` then
requires the key args:

```ts theme={null}
export const DeleteNote = Confect.Command.mutation(
  "DeleteNote",
  refs.public.notes.delete,
  {
    messages: [SucceededDeleteNote, FailedDeleteNote],
    onSuccess: () => SucceededDeleteNote(),
    onError: (error) => FailedDeleteNote({ message: String(error) }),
    interrupt: {
      keyFields: ["noteId"],
      toKey: ({ noteId }) => noteId,
    },
  },
);

// In update:
// [model, [DeleteNote.Interrupt({ noteId }, (outcome) => CompletedCancelDeleteNote({ outcome }))]]
```

<Note>
  Interruption stops the client-side Effect and guarantees the invocation's
  result Messages never dispatch—it does not cancel the Convex function on the
  server. Once a mutation or action is on the wire, it runs to completion;
  interruption means its result is ignored. Foldkit also runs a batch of
  Commands concurrently with no ordering guarantee, so to cancel and replace,
  dispatch the replacement from the Interrupt's result Message rather than
  returning both Commands in one batch.
</Note>

### Effect helpers

`Command.queryEffect`, `Command.mutationEffect`, and `Command.actionEffect`
return an execute body—an `Effect` whose failures are already folded into
Messages—for hand-written `Command.define` calls. Reach for them when the
Command needs a custom args schema or several calls in one Command:

```ts theme={null}
import * as Confect from "@confect/foldkit";
import * as FoldkitCommand from "foldkit/command";
import * as Schema from "effect/Schema";

const saveNote = Confect.Command.mutationEffect(refs.public.notes.insert, {
  onSuccess: (noteId) => SucceededSaveDraft({ noteId }),
  onError: (error) => FailedSaveDraft({ message: String(error) }),
});

export const SaveDraft = FoldkitCommand.define("SaveDraft", {
  args: { text: Schema.String, localDraftId: Schema.String },
  messages: [SucceededSaveDraft, FailedSaveDraft],
  execute: ({ text }) => saveNote({ text }),
});
```

## Query state in the Model

Foldkit's [`AsyncData`](https://foldkit.dev/core/async-data) is the idiomatic
Model representation for query state. Map errors to a view-ready schema type
in your `onError` handler, then settle the `AsyncData` field with `Match.tags`
in `update`:

```ts src/model.ts theme={null}
import { AsyncData } from "foldkit";
import { m } from "foldkit/message";
import * as Schema from "effect/Schema";

const Notes = AsyncData.Schema(Schema.Array(Note), Schema.String);

export const Model = Schema.Struct({ notes: Notes.schema });

export const SucceededGetNotes = m("SucceededGetNotes", {
  notes: Schema.Array(Note),
});
export const FailedGetNotes = m("FailedGetNotes", { message: Schema.String });
```

```ts src/update.ts theme={null}
Match.tags({
  SucceededGetNotes: ({ notes }) => [
    evo(model, { notes: () => Notes.Success({ data: notes }) }),
    [],
  ],
  FailedGetNotes: ({ message }) => [
    evo(model, { notes: () => Notes.Failure({ error: message }) }),
    [],
  ],
}),
```

## Paginated queries

`PaginatedQuery` navigates a [paginated
query](/v10/server/functions#paginated-queries) one page at a time—next and
previous—over Convex's cursor-based pagination. The machine lives in your
Model and is the single source of truth for one live, reactive page
subscription. Navigation and page-split handling are pure Model transitions,
and `Subscription.paginatedQuery` keeps the subscription in sync with them.

`PaginatedQuery.make` takes the ref and returns the machine's Model and
correlated-settlement schemas together with its operations. The machine uses
the same vocabulary as Foldkit's `AsyncData`: `Idle`, `Loading`, `Refreshing`,
`Success`, `Failure`, and `Stale`. `Refreshing` and `Stale` retain the complete
last good page.

```ts src/model.ts theme={null}
import * as Confect from "@confect/foldkit";
import { m } from "foldkit/message";
import * as Schema from "effect/Schema";

import refs from "../confect/_generated/refs";

export const Notes = Confect.PaginatedQuery.make(refs.public.notes.paginate);

export const Model = Schema.Struct({
  notes: Notes.schema,
});

export const initialModel = Model.make({ notes: Notes.idle });

export const SettledGetNotesPage = m("SettledGetNotesPage", {
  settlement: Notes.settlement,
});
```

Wire the subscription entry to the machine state:

```ts src/subscription.ts theme={null}
import * as Confect from "@confect/foldkit";
import * as Subscription from "foldkit/subscription";

export const subscriptions = Subscription.make<
  Model,
  Message,
  Confect.Client.Client
>()(() => ({
  notesPage: Confect.Subscription.paginatedQuery<Model>()(Notes, {
    state: (model) => model.notes,
    onSettled: (settlement) => SettledGetNotesPage({ settlement }),
  }),
}));
```

When an active machine has no pagination id, the subscription allocates one
from the application-scoped `Client`, opens the Convex query, and includes the
id in its first `Result`-based settlement. `settle` installs the id and result
atomically. Allocation is not a separate application event, and installing the
id does not restart the live subscription.

Like `AsyncData.settle`, a success becomes `Success`; a failure becomes `Stale`
when a page is held and `Failure` otherwise. The request carries a logical
generation as well as its cursor and page identity, so outcomes superseded by
navigation, new args, close/reopen, or reset are ignored. All public machine
operations remain pure:

```ts src/update.ts theme={null}
import * as Confect from "@confect/foldkit";
import * as Match from "effect/Match";
import * as Option from "effect/Option";
import type * as Command from "foldkit/command";

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>];

export const update = (model: Model, message: Message): UpdateReturn =>
  Match.value(message).pipe(
    Match.withReturnType<UpdateReturn>(),
    Match.tagsExhaustive({
      OpenedNotes: ({ channel }) => {
        const notes = Match.value(model.notes).pipe(
          Match.tag("Idle", (idle) =>
            Notes.init(idle, { channel }, { initialNumItems: 20 }),
          ),
          Match.tag("Active", (active) =>
            Notes.reinitialize(active, { channel }, { initialNumItems: 20 }),
          ),
          Match.exhaustive,
        );
        return [evo(model, { notes: () => notes }), []];
      },
      SettledGetNotesPage: ({ settlement }) => [
        evo(model, {
          notes: Confect.PaginatedQuery.settle(settlement),
        }),
        [],
      ],
      ClickedNextPage: () => {
        const notes = Match.value(model.notes).pipe(
          Match.tag("Idle", (idle) => idle),
          Match.tag("Active", (active) =>
            Option.getOrElse(Confect.PaginatedQuery.next(active), () => active),
          ),
          Match.exhaustive,
        );
        return [evo(model, { notes: () => notes }), []];
      },
      ClosedNotes: () => {
        const notes = Match.value(model.notes).pipe(
          Match.tag("Idle", (idle) => idle),
          Match.tag("Active", Confect.PaginatedQuery.close),
          Match.exhaustive,
        );
        return [evo(model, { notes: () => notes }), []];
      },
    }),
  );
```

`getPage` and `getItems` return `Option`s because the initial load can have no
data. Once a page succeeds, they keep returning that page while the next one
loads and after a refresh fails. `Page.number` is the page being displayed;
`targetPageNumber` is the page currently requested, which can differ during a
navigation:

```ts theme={null}
const items = Option.getOrElse(
  Confect.PaginatedQuery.getItems(state),
  () => [],
);
const label = Match.value(state).pipe(
  Match.tag("Idle", () => "Closed"),
  Match.tag("Active", (active) =>
    Option.match(Confect.PaginatedQuery.getPage(active), {
      onNone: () =>
        `Loading page ${Confect.PaginatedQuery.targetPageNumber(active)}`,
      onSome: (page) => `Page ${page.number}`,
    }),
  ),
  Match.exhaustive,
);
const nextDisabled = !Confect.PaginatedQuery.canNext(state);
const prevDisabled = !Confect.PaginatedQuery.canPrev(state);
```

`isIdle`, `isLoading`, `isRefreshing`, `isSuccess`, `isFailure`, and `isStale`
are refinements; `isPending`, `hasPage`, `hasError`, and the exhaustive `match`
cover the common view branches. A `Failure` or `Stale` does not close the
subscription: Convex query errors are deterministic rather than manually
retryable, and the live query may recover after its data, arguments,
authentication, or deployment changes.

Failed settlements carry an exhaustive error union: `FunctionError` wraps an
error declared by the query or its middleware, `WebSocketClientError`
represents an unexpected client failure, and `SchemaError` is carried directly
from argument encoding or result decoding. Convex's `InvalidCursor`
pseudo-error is also represented explicitly in settlements, but `settle`
handles it internally by starting a fresh session at page one while retaining
the displayed page.

`first` is ordinary navigation back to page one within the current session.
`reset` purely requests a fresh session at page one while retaining the
displayed page, and `reinitialize` changes query args or page options and starts
again from page one. `close` returns to `Idle` while retaining a generation
tombstone, so a later `init` cannot accept a late first settlement from an
earlier identical session. Options use Convex's names: `initialNumItems` is
required, while `maximumRowsRead` and `maximumBytesRead` are optional positive
integers.

When you navigate forward, the page you leave is pinned to the range it
displayed—from its cursor to its continuation cursor—so going back reloads
exactly that range, however the data has moved since. This is what keeps
consecutive pages gap-free and duplicate-free for
[stream-paginated queries](/v10/server/database/streams#paginating-a-stream), which have no
query journal to remember page ranges. The page currently on screen is a live
window of `initialNumItems` documents from its cursor, so it stays full as
documents are inserted and deleted.

<Note>
  Convex may signal that a page has grown too large by recommending or requiring
  a page split. The machine handles the full protocol transparently: the current
  page is pinned to end at the split point and reloads as a range query, and the
  next page picks up from there. Users see at most a brief reload of the current
  page—never a torn or incomplete one. If a reactive terminal page becomes
  empty, the machine automatically retreats to the previous page.
</Note>

## Authentication

`setAuth` is available on the `Client` service. Wire it up as a
Command:

```ts theme={null}
import * as Confect from "@confect/foldkit";
import * as Effect from "effect/Effect";
import * as FoldkitCommand from "foldkit/command";

const SetAuth = FoldkitCommand.define("SetAuth", {
  messages: [CompletedSetAuth],
  execute: Effect.flatMap(Confect.Client.Client, (client) =>
    client.setAuth(({ forceRefreshToken }) => fetchToken(forceRefreshToken)),
  ).pipe(Effect.as(CompletedSetAuth())),
});
```

## Not yet covered

An infinite-scroll, load-more counterpart to `@confect/react`'s
`usePaginatedQuery` (a growing list of concurrently-live pages) is not yet
built in. `PaginatedQuery` covers page-at-a-time navigation; for a growing
list, paginated refs can be called through the underlying
[`WebSocketClient`](/v10/clients/js/websocket) service directly.
