@collective-intelligence/external-apps-client-sdk-react
UI-minimal React bindings over the Promise Client SDK. The package has no private application UI dependencies.
CollectiveIntelligenceProvider owns a stable, request-isolated QueryClient; applications do not
configure a QueryClient for SDK CRUD. To use one cache for the SDK hooks and the application, pass
the application's client as queryClient. Then the defaults of that client apply to the /resources
reads, and the application isolates requests on the server. Entity hooks keep retry: false.
Install these required peer dependencies in the application: react, react-dom,
@tanstack/react-query, xstate, effect and @collective-intelligence/ui-react. The package
depends on @collective-intelligence/external-apps-client-sdk as a normal dependency. The packed
JavaScript bundles none of these packages, so the application holds one copy of each. The ./effect entry
holds the Effect bridge for XState machines.
Configure the provider's entitySource once, alongside its existing Promise client. EntitySource
exposes a stable getSnapshot, a subscribe function, and a typed bind(contract) function returning
the common Promise EntityClient. Authenticated snapshots identify the configured logical source,
application, user, and organization. Obtain those identities from the authenticated application
session; they are cache boundaries, not access grants. Return a new stable snapshot for every session
transition, notify subscribers immediately on logout, and bind clients to that snapshot's authority.
Backend authorization remains authoritative. CollectiveIntelligenceLoginProvider forwards the same
configuration; the entity source must subscribe to the application's CI-derived session lifecycle.
;<CollectiveIntelligenceProvider client={appSdk} entitySource={entitySource}>
<Orders />
</CollectiveIntelligenceProvider>
// Import these hooks from /resources. Use the portable entity contract.
const rows = useEntityList(orderContract, query)
const record = useEntityRecord(orderContract, { id: selectedId })
const create = useEntityCreate(orderContract)
const update = useEntityUpdate(orderContract, { id: selectedId })
const remove = useEntityDelete(orderContract, { id: selectedId })
await update.submit({ value: draft, expectedRevision: editedRecord.revision })
// Only on an explicit user request to deliver the same action again:
await update.resubmit()
Reads expose data, loading, refreshing, error, authState, effective binding capabilities,
and explicit refresh; list reads also expose empty. Equivalent requests share data across views.
Cache identity includes source/application/user/organization and resource/version/query, never view
namespaces or layout settings.
Writes expose available, record-scoped pending, status, data, error, submitted, submit,
and resubmit. Status distinguishes conflict, rejected, and uncertain from ordinary
idle/pending/success. Submission clones and freezes the draft and reviewed revision and generates
one operation key. A new submit creates a new action; resubmit preserves the previous action exactly.
Keep editable drafts in the form and use submitted for the retained action; a successful replay
does not overwrite a newer draft. Same-record update/delete calls serialize across hook instances;
different records and separate creates remain independent. Queued writes retain their original
revisions. Form unmount does not cancel a dispatched write. Source transitions clear protected reads
and reject stale queued actions before dispatch. Queries and mutations never automatically retry;
there is no persistence, reload recovery, or automatic revision rebasing.
Entity reads and writes run on the Effect Core of the client SDK, through the entity clients that
the entity source binds. Their errors stay ResourceResult values: a read that fails gives
error, a ResourceQueryError with the resource error; a write that fails in flight is
uncertain. Other client SDK calls follow the client SDK error model: a CiResult operation
resolves { ok: false, error } with a CiError, and rejects with CiTransportError when the
request does not reach CI. useAgentInvocation shows such a rejection as its error state.
The old public TanStack option factories and native-result resource hooks are no longer exported.
Use the deliberate entity hooks above. Existing generic slotted Resource* presentation remains
separate from common entity CRUD.
Optimistic Writes
Entity writes are Optimistic Writes. The value is in the QueryClient cache, so every view that reads the record changes at once:
- Before CI answers an update or a delete, the record view and each cached list page show the
new value. A deleted record has
deletedAtin its record view and leaves the list pages. - A create shows a pending row at the start of each cached first list page. Its
idis the idempotency key and itsvalueis the create input. CI's record replaces it. - To change the cache only when CI answers, pass
optimistic: falsein the target:useEntityUpdate(contract, { id, optimistic: false }), also foruseEntityDelete. - Writes to one record run one at a time, in call order. Providers sharing a QueryClient share this ordering; separate QueryClients and record identities remain independent. An entity read of a record waits until the pending write of that record settles, and a list read waits for the pending writes of its resource. Thus a refetch after a realtime change or a Resync does not replace the shown value.
- On success, the views show CI's record, and the write ends. Then each list view of the resource reads CI once more, because the write can change which records a list holds and in which order. The record view does not read again when it holds CI's record; after a failure, or when another fetch replaced it, it reads CI once more too. The write does not wait for these reads.
- On a failure or an interruption, a view goes back to its value from before the write, but only
when it still shows the write's value. In a list page, only the write's own row changes, so
the rows of pending writes to other records stay. A read outside the entity hooks, for example
an app
fetchQueryon an entity key, does not wait for a write; the view then keeps that newer value. The read after the write shows CI's value in each case.
onWriteFailed on CollectiveIntelligenceProvider or CollectiveIntelligenceLoginProvider
gets a WriteFailed for each entity write that CI refuses or that does not reach CI, after the
views went back. error is a CiError (CI refused the write) or a CiTransportError (the
request did not reach CI, or its answer was lost); operation is create, update or
delete; label is the resource id (contract.id). It is not called for an interruption, or
when the entity source changed during the write. The provider calls the onWriteFailed of the
latest render. . and ./effect export the WriteFailed type. This package shows no toast:
ciWriteFailedToast(messages?) of @collective-intelligence/ui-react/ui returns a handler that
shows one in the app's Toaster.
import {
Toaster,
ciWriteFailedToast
} from "@collective-intelligence/ui-react/ui"
;<CollectiveIntelligenceProvider
client={appSdk}
entitySource={entitySource}
onWriteFailed={ciWriteFailedToast()}
>
<Orders />
<Toaster />
</CollectiveIntelligenceProvider>
Declared entity views
Import defineEntity, EntityTable, EntityList, and EntityForm from
@collective-intelligence/external-apps-client-sdk-react/resources. Declare
presentation once alongside the portable contract:
const orders = defineEntity({
contract: orderContract,
config: {
fields: { number: { label: "Order number" } },
table: {
columns: ["number"],
variants: { compact: { columns: ["number"] } }
},
list: { title: "number" },
forms: {
create: { fields: ["number"] },
update: { fields: ["number"] }
}
}
})
// Inside the SDK provider and the host framework's NuqsAdapter:
<EntityTable entity={orders} namespace="orders-main" />
<EntityList entity={orders} namespace="orders-sidebar" />
<EntityTable entity={orders} viewState="memory" variant="compact" />
<EntityForm entity={orders} mode="update" record={selectedOrder} />
The declaration contains labels, field/control/renderer keys and layouts, with
no JSX, credentials or storage configuration. Field names are inferred from
the contract; configured fields must also exist in its portable schema.
Views render public ui-react presentation and share authorized query data
when their normalized queries match.
URL-backed tables/lists require a stable, distinct namespace. Install the host
adapter once; the proven TanStack Start integration uses
NuqsAdapter from nuqs/adapters/tanstack-router inside the router root.
Nuqs 2.10.1 owns URL serialization and replace-history updates. Defaults are
omitted; unrelated keys, other namespaces and the hash are preserved. Invalid
owned values fall back to defaults. Search/filter/sort changes reset the page.
For custom controls, use useEntityViewState(namespace, { contract, pageSize });
it exposes state, the normalized query, and setters for page, page size,
search, filters and sorting. useMemoryEntityViewState and
viewState="memory" need no URL adapter. View namespaces never enter data keys.
Forms infer scalar, enum, date, nullable and required settings from portable
schemas. Readonly schema fields cannot become writable through configuration
and are omitted from submissions. Richer values need a named control in the
form's controls registry; missing controls produce explicit feedback.
Custom controls receive the field metadata and return JSON wire values.
Named display renderers are supplied separately through renderers.
Pending writes disable overlapping saves. Conflict and unknown-outcome states
keep the local draft; Resubmit sends the captured original action, including
its key, values and revision. Successful resubmission also keeps newer edits.
An update form retains the revision its draft began editing when refreshed
record props arrive. Remount it intentionally (for example with a React key)
after the user chooses to discard the draft and edit a newer server revision.
Drafts and operation keys never enter the URL.
Realtime topics
RealtimeProvider takes the options of createRealtimeClient as props. It
creates one client, and so one connection, for its tree, and closes it when the
tree unmounts. Its effects and the hooks below it hold the client by reference
count. Thus a Strict Mode double mount opens one stream, and a server render
creates no client. Without a document prop the client follows the page of
the browser. The client reads the other props, such as ticketUrl and
fetch, once, when the provider creates it; a later change has no effect. To
use new values, remount the provider, for example with a React key.
useRealtimeTopic(topic, onChanges) subscribes while the component is mounted
and unsubscribes on unmount. A new topic object for the same topic keeps the
subscription; null subscribes to nothing. The latest onChanges is called
without resubscribing. It receives the changes of the topic in batches: one
delivery per tab, 250 to 1000 ms after the first pending change, with the
latest change of each record.
Pass onReset and refetch every realtime-driven query in it. The client calls
it when the server cannot replay a gap and once after a stream opens without a
last event id, the first one of each page included. Without onReset the
client cannot recover from such a gap, so a change can be lost. The client
calls onAccessChanged when the user's access changes. Both stay callbacks of
the application; the client calls the ones of the latest render, also when a
render adds or removes one. Only a client with onReset resyncs, so a render
that adds or removes onReset replaces the client and its stream. The new
stream opens without a last event id, so with onReset it resyncs once.
const AppRealtime = ({ children }: { children: ReactNode }) => {
const queryClient = useQueryClient()
return (
<RealtimeProvider
ticketUrl="/api/realtime/ticket"
onReset={() => queryClient.invalidateQueries()}
onAccessChanged={() => queryClient.invalidateQueries()}
>
{children}
</RealtimeProvider>
)
}
const LeadBoard = () => {
const queryClient = useQueryClient()
useRealtimeTopic({ schema: "crm.lead" }, () =>
// One invalidation per batch, however many leads changed.
queryClient.invalidateQueries({ queryKey: ["leads"] })
)
// …
}
For the entity hooks, useEntityChanges(contract) of ./resources gives the
onChanges that refreshes their reads. The record entry of a changed id
reads CI again, unless it already holds the revision of the change, such as
the record that the tab's own write stored. Every list of the resource reads
CI again, because a change can change which records a list holds. Each read
waits for the pending writes of its records.
const LeadRealtime = () => {
useRealtimeTopic({ schema: "crm.lead" }, useEntityChanges(leadContract))
return null
}
Effect bridge
./effect connects XState machines to Effect and to the application's
QueryClient. It has no Effect Atom bindings.
WriteFailedis the type of theonWriteFailedevent.fromEffect(run)makes actor logic from an Effect. The actor runs the effect oninput.runtime, so annotateinputwith aruntime: EffectRunner<R>that provides every service of the effect. The actor's output is the effect's value; on a failure,onErrorgets the typed error or the defect. A stop of the actor interrupts the fiber, and the run ends. After the stop, no output, error or interruption of that run gets to the actor, also when React Strict Mode starts the actor ofuseActorRefagain.EffectRuntimeProvidergives its tree a runtime withQueryCacheover the nearest QueryClient;useEffectRuntime()returns it. Put it insideCollectiveIntelligenceProvider. The runtime holds no resource, so the provider never disposes it.QueryCacheis the one way for machine actors to read and write remote data; machine context holds none.fetch({ queryKey, query, staleTime? })returns a fresh cached value or runsqueryonce, with no automatic retry, and caches its value.CiErrorandCiTransportErrorstay typed errors; another failure is a defect. While afetchwaits, it keeps the key in use, as a mounted view does. When the last view orfetchof the key stops waiting, for example because its actor stops, TanStack Query cancels the fetch andqueryis interrupted. Thus in React Strict Mode, the actor thatuseActorRefstarts again can runquerya second time. A cancel of the key, for examplequeryClient.cancelQueries({ queryKey }), also interruptsquery. Afetchthat waits then returns the cached value, or is interrupted when the key has no value.invalidate(queryKey)marks values stale and refetches the ones that views show.cancel(queryKey)cancels the running fetches and interrupts their queries, so they write no value; it keeps the cached values and starts no fetch. An optimistic write callscancel, thenupdate, runs the write, and callsinvalidatewhen the write settles.setData(queryKey, value)writes a value that views show at once.update(queryKey, (current) => next)replaces each cached value with the result of the function for its current value, without a refetch, for example an optimistic toggle or a changed row; views show the new values at once. Each key underqueryKeymust hold a value of the type that the function takes.currentisundefinedfor an entry without a value, anundefinedresult keeps the value, and a key that the cache does not hold stays absent.updatedoes not cancel a running fetch, which can replace the new value when it ends: callcancelfirst.remove(queryKey)removes the cached entries without a refetch, for example at sign-out; it cancels a running fetch of a removed key, and the nextfetchruns its query. A mounted view of a removed key keeps its last result until it renders again.invalidate,cancel,updateandremoveapply toqueryKeyand to each key below it (["crm", "lead"]matches["crm", "lead", "7"], not["crm", "leads"]);setDatawritesqueryKeyonly. Views read the same keys withuseQuery.
import type { EffectRunner } from "@collective-intelligence/external-apps-client-sdk-react/effect"
import {
EffectRuntimeProvider,
QueryCache,
fromEffect,
useEffectRuntime
} from "@collective-intelligence/external-apps-client-sdk-react/effect"
const loadLead = fromEffect(
(input: Readonly<{ id: string; runtime: EffectRunner<QueryCache> }>) =>
QueryCache.use((cache) =>
cache.fetch({ queryKey: ["crm", "lead", input.id], query: readLead(input.id) })
)
)
<CollectiveIntelligenceProvider client={appSdk} queryClient={queryClient}>
<EffectRuntimeProvider>
<LeadEditor id={leadId} />
</EffectRuntimeProvider>
</CollectiveIntelligenceProvider>
const LeadEditor = ({ id }: { id: string }) => {
const runtime = useEffectRuntime()
// `leadMachine` invokes `loadLead` with `{ id, runtime }` from its input.
const lead = useActorRef(leadMachine, { input: { id, runtime } })
// …
}
Machine tests
Test a machine with makeTestQueryCache(options?), never with a double made
with QueryCache.of({ ... }): that double breaks when QueryCache gets a new
member. makeTestQueryCache returns:
layer: provides theQueryCacheof./effect, the service thatEffectRuntimeProviderprovides. Give it toManagedRuntime.makefor theruntimeof the machine input.queryClient: the real TanStackQueryClientunder the cache. Each member works on it as in the app, with the same key prefixes,cancelandremove. Its entries stay until the test ends (gcTime: Infinity).calls: one{ member, queryKey }for each member call, in the order that the calls ran. An effect that the machine only builds is no call.
Options, all optional: seed ([queryKey, data] entries that the
QueryClient holds first), queryClient (to use your own), and overrides
(a Partial of the members: each override replaces its member only, and the
call log records it too).
Assert on the entries, so a machine that skips a call fails the test:
import {
QueryCache,
makeTestQueryCache
} from "@collective-intelligence/external-apps-client-sdk-react/effect"
import * as ManagedRuntime from "effect/ManagedRuntime"
const cache = makeTestQueryCache({
seed: [[["ci-entities", "lead", "7"], lead]]
})
const runtime = ManagedRuntime.make(cache.layer)
const actor = createActor(sessionMachine, { input: { runtime } }).start()
actor.send({ type: "session.signOut" })
await waitFor(actor, (snapshot) => snapshot.matches("idle"))
// Sign-out removed the reads of the user.
expect(
cache.queryClient.getQueryCache().findAll({ queryKey: ["ci-entities"] })
).toEqual([])
expect(cache.calls).toEqual([{ member: "remove", queryKey: ["ci-entities"] }])
// After an invalidate: cache.queryClient.getQueryState(key)?.isInvalidated === true
await runtime.dispose()
makeTestQueryCache adds no dependency, and a production bundle that does
not import it does not contain it (sideEffects: false).
Workspace development
See the repository's Effect demo for the same declared Lead views configured against local Gateway, proxied CI, and direct CI bindings.
Each package export declares an opt-in ci-source condition for workspace development. The local
TypeScript configuration enables it for the framework-neutral Client SDK, so either package's changes
are consumed without an intermediate rebuild. Release builds disable custom conditions; installed npm
consumers resolve generated dist JavaScript and declarations. Shared runtime chunks keep provider
contexts identical across the root and /resources entrypoints.
Run bun run test:unit, bun run type-check, bun run lint, and bun run test:pack. The pack check
executes a real root-provider/resources-hook consumer without caller TanStack setup, and a ./effect
consumer that shares the application's QueryClient. It removes its temporary installation on success
or failure. The repository root runs bun run lint:effect for the Effect rules.
Enum value compatibility
Repository-owned enum values use lower snake_case. Update gateway manifests,
request payloads and saved resource filters together with their consumers. For
example, resource filters use starts_with and is_null, lifecycle operation
values use soft_delete and hard_delete, and gateway principal kinds use
detached_user and service_principal. Public method and capability property
names such as softDelete remain unchanged. External HTTP, OAuth, JSON Schema
and provider values retain their required spelling at their adapter boundaries.