Skip to main content
@confect/foldkit bridges Confect into Foldkit, 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, reactive queries become Subscription entries, and queries, mutations, and actions become Commands. Args and return values are encoded and decoded through the Effect Schemas in your function specs, 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 carry no schemas, so they aren’t accepted.

Installation

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:
With npm, use an overrides entry that pins effect to the version your app uses.

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.
src/entry.ts
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).
src/subscription.ts
onSuccess receives the decoded return value on every server update; onError receives the ref’s decoded typed error (see 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:

Custom entries

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

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:
src/command.ts
src/update.ts
Follow Foldkit’s naming convention: 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. 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:
Build the interrupt Command from its Match.tag update handler:
interrupt: { keyFields, toKey } derives the key from the ref’s args, so concurrent invocations can be interrupted independently. Interrupt then requires the key args:
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.

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:

Query state in the Model

Foldkit’s AsyncData 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:
src/model.ts
src/update.ts

Paginated queries

PaginatedQuery navigates a paginated query 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.
src/model.ts
Wire the subscription entry to the machine state:
src/subscription.ts
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:
src/update.ts
getPage and getItems return Options 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:
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, 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.
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.

Authentication

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

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 service directly.