TanStack Query is the async layer over visits and actions, not a REST API
the pack invents. The host seed is still the only read model. Query makes
“write, then refetch” fluent — same action().
The document seed hydrates a QueryClient (['keel','page'] → seed).
There is no extra GET on first paint. staleTime is Infinity until
something invalidates.
useAction(id) is a mutation: POST /__keel/action/{id}, then
router.reload() so page() and the cache rehydrate from the host.
This page uses the Svelte binding. Every adapter exposes the same
mutate-then-visit pattern with its own name (useAction in React, Vue,
Solid, and Preact; injectKeelAction in Angular; useAction controllers in
Lit) — see Framework
adapters.
import { Head, page, useAction } from "@kolektiv/keel-svelte"
import type { HomePage, PostMessageIn, PostMessageOut } from "../lib/page-types"
const ctx = page<HomePage>()
const postMessage = useAction<PostMessageIn, PostMessageOut>("harbor.postMessage")
function onPost(event: SubmitEvent) {
event.preventDefault()
void postMessage.mutateAsync({ body })
}import { Head, page, useAction } from "@kolektiv/keel-svelte"
const ctx = page()
const postMessage = useAction("harbor.postMessage")
function onPost(event) {
event.preventDefault()
void postMessage.mutateAsync({ body })
}Harbor’s Svelte bootstrap creates the client, hydrates from #__keel_seed,
and keeps the cache aligned when visits (Link) call setPage.
useAction / useKeelPageQuery pass that client explicitly.
Actions and effects
useAction(id) returns a reactive proxy. State reads (isPending, error, …)
subscribe the current reactive scope to the mutation store; method reads
(mutateAsync, reset) do not. Reading state inside an effect still makes it
re-run on every mutation transition, so an effect-driven load that checks
isPending can fire the action again and again: an endless
POST /__keel/action/{id} loop that hammers the host and flashes the spinner.
useAction mutations also reload the page by default: on success they
router.reload() and rehydrate the seed. That reload is a navigation, so
registered guards run with source: "reload" before the seed is fetched —
see Navigation guards.
reload: false skips that visit; it does not stop an effect from re-reading
action state.
<script lang="ts">
import { useAction } from "@kolektiv/keel-svelte"
const getSharing = useAction("app.getSharing", { reload: false })
$effect(() => {
if (getSharing.isPending) return
void getSharing.mutateAsync({ id })
})
</script><script lang="ts">
import { untrack } from "svelte"
import { useAction } from "@kolektiv/keel-svelte"
let { sharingId } = $props()
const getSharing = useAction("app.getSharing", { reload: false })
let loadedId: string | undefined
$effect(() => {
const id = sharingId
if (!id || loadedId === id) return
loadedId = id
untrack(() => {
getSharing.reset()
void getSharing.mutateAsync({ id })
})
})
</script>Prefer event handlers for user-initiated writes. When a load belongs in an
effect, guard it by a key so it runs once per open/id and keep action state
reads inside untrack(...).
Do not add /api/messages. A pack that fetches a second JSON source is
against protocol. See
Server actions. The
invalidation rule does not change: mutate, visit, rehydrate the seed.