Skip to content

Advanced cases

Mutate and rehydrate

TanStack Query invalidates. A visit reloads the seed. No second API.

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.

useAction
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.

WRONG: effect reads action state, then fires the action
<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>
RIGHT: guard by key, untrack proxy access
<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.