Advanced

HTTP.

Aktion has exactly one network primitive: $http({ url, method, headers, body, query, … }). Every call is self-contained — it carries a full absolute URL and all of its options, returns a reactive resource bag, and fires the request the moment its binding mounts. There are no host-wide defaults, no base URL, and no hidden configuration.

The basics

Assign the result of $http({...}) to a reactive atom. The request fires once when the binding mounts; the atom holds a reactive bag whose fields update in place as the request resolves.

$todos = $http({
  url:    "https://api.example.com/todos",
  method: "GET"                        // GET is the default — this line is optional
})

Because GET is the default method, a read is often a one-liner:

$todos = $http({ url: "https://api.example.com/todos" })

Config options

KeyTypeNotes
urlstringRequired. A full absolute URL, e.g. "https://api.example.com/todos".
methodstring"GET" (default), "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS".
queryobjectSerialised into the URL querystring. { limit: 5, status: "open" }?limit=5&status=open. Appended with & if the URL already has a querystring; null / undefined entries are skipped.
headersobjectPlain object of request headers.
bodyanyRequest body. Plain objects are JSON-encoded automatically and a Content-Type: application/json header is added unless you set one. Strings, FormData, Blob, URLSearchParams, and ArrayBuffer are passed through untouched. Ignored for GET / HEAD.
…restfetch optionsAny other fetch option (credentials, mode, cache, redirect, referrer, …) is forwarded verbatim to the underlying request.
$orders = $http({
  url:         "https://api.example.com/users/" + $userId + "/orders",
  method:      "GET",
  query:       { limit: 5, status: "open" },   // → ?limit=5&status=open
  headers:     { "X-Tenant": $tenant },
  credentials: "include"                        // forwarded straight to fetch
})

The reactive resource bag

Every $http({...}) call returns the same shape. The fields mutate in place and the runtime re-renders as they change, so you can read them anywhere in your tree.

$orders.data         // parsed response body (null until resolved)
$orders.error        // null on success; { status, body } on a non-2xx; the thrown error on network failure
$orders.status       // HTTP status code, e.g. 200
$orders.loading      // true while in-flight
$orders.headers      // response headers as a plain object
$orders.lastUpdated  // ms-epoch of the last successful response
$orders.refetch()    // re-issue the request
$orders.cancel()     // abort the in-flight request
$orders.onDone = fn  // callback fired each time the request settles

A response is treated as successful when its status is in the 2xx range. A 204 No Content resolves with data === null. Non-2xx responses populate error (and still set status / headers) rather than throwing.

The onDone callback

Assign onDone after creating a resource to run something every time the request settles. It fires once per completion — the initial load and every refetch(), on both success and error — and receives the resource bag as its argument. It never fires for a request that was superseded by a newer refetch() or cancel()led. This is the idiomatic way to refresh a list after a mutation without threading the refresh through every call site:

$patch = $http({
  url:    endpoint + "/" + todo.id,
  method: "PATCH",
  body:   { isCompleted: !todo.isCompleted }
})

$patch.onDone = () => {
  $todos.refetch()
}

The Async helper

Async(resource, { loading, error, empty, data }) renders the right branch based on the resource’s state. It is the cleanest way to handle the four states of any request. The empty slot is shown when the resolved data is null or an empty array.

$app(Async($todos, {
  loading: LoadingState("Loading todos…"),
  error:   ErrorState("Couldn't fetch todos"),
  empty:   EmptyState("No todos yet"),
  data:    Table([
    Col("Item", $todos.data.title),
    Col("Done", $todos.data.isCompleted)
  ])
}))

Branching order is: loading (while in-flight) → error (when .error is set) → empty (when data is null or an empty array) → data. Any slot you omit simply renders nothing.

Writes — POST / PUT / PATCH / DELETE

Mutations are the same function with a non-GET method. Fire them from inside an action body and observe the resulting bag for progress and error states. Refresh a list resource with .refetch() after the write succeeds.

$todos = $http({ url: "https://api.example.com/todos" })
$draft = ""

function addTodo() {
  if (!$draft) { return }
  $create = $http({
    url:    "https://api.example.com/todos",
    method: "POST",
    body:   { title: $draft, isCompleted: false }
  })
  $draft = ""
  $todos.refetch()
}

Re-running a request

A request fires once when its binding mounts. It does not magically re-run when a $atom it reads changes. To re-issue a request you have two tools:

// Re-run the search whenever $query changes, debounced 300ms.
$effect(() => {
  $results = $http({ url: "https://api.example.com/search", query: { q: $query } })
}, [$query, "debounce(300)"])

// Poll an endpoint every 30 seconds.
$effect(() => {
  $todos.refetch()
}, ["every(30000)"])

Cancelling

Call .cancel() to abort an in-flight request. The loading flag clears and the resource settles back to its previous resting state. A new refetch() always supersedes an older in-flight request — a slow earlier response can never clobber a newer result.

cancelBtn = Button("Cancel", { onClick: () => $upload.cancel(), variant: "ghost" })

Full CRUD walkthrough

Putting it together against a small Todo API: list, create, toggle, and delete. Each mutation refetches the list so the UI stays in sync.

endpoint = "https://api.example.com/todos"

$todos = $http({ url: endpoint })
$draft = ""

function addTodo() {
  if (!$draft) { return }
  $create = $http({ url: endpoint, method: "POST", body: { title: $draft } })
  $draft  = ""
  $todos.refetch()
}

function toggleTodo(todo) {
  $patch = $http({
    url:    endpoint + "/" + todo.id,
    method: "PATCH",
    body:   { isCompleted: !todo.isCompleted }
  })
  $todos.refetch()
}

function deleteTodo(todo) {
  $del = $http({ url: endpoint + "/" + todo.id, method: "DELETE" })
  $todos.refetch()
}

todoRow = todo => Stack([
  Checkbox("done-" + todo.id, { value: todo.isCompleted, onChange: () => toggleTodo(todo) }),
  Text(todo.title, { tone: todo.isCompleted ? "muted" : "default" }),
  Button("Delete", { onClick: () => deleteTodo(todo), variant: "ghost", tone: "danger", size: "sm" })
], { direction: "row", gap: "sm", align: "center" })

$app(Stack([
  PageHeader("Todos", { actions: [Button("Refresh", { onClick: $todos.refetch, variant: "ghost" })] }),
  Stack([
    Input("draft", { placeholder: "What needs doing?", value: $draft }),
    Button("Add", { onClick: addTodo, variant: "primary" })
  ], { direction: "row", gap: "sm" }),
  Async($todos, {
    loading: LoadingState("Loading todos…"),
    error:   ErrorState("Couldn't fetch todos"),
    empty:   EmptyState("No todos yet"),
    data:    Stack($todos.data.map(todoRow), { direction: "column", gap: "sm" })
  })
], { direction: "column", gap: "lg" }))

See this exact pattern running live in the live demos.

Host interceptors

The request function is intentionally free of cross-cutting concerns like auth and logging. Host integrators attach those once via el.registerHttpInterceptors(...); the hooks fire around every $http({...}) request issued by the response.

el.registerHttpInterceptors({
  onRequest:  req => ({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token}` } }),
  onResponse: (res, retry) => res.status === 401 ? retry() : res,
  onError:    (err, req) => console.error("request failed", req.url, err)
})

See the Frameworks guide for full host-integration examples.

In‑program interceptors

Host interceptors live outside the program. To inject auth or retry logic from inside an Aktion program, register $util.onRequest / $util.onResponse. They run on a separate layer from host interceptors and are wiped on every re‑plan, so they never leak between programs.

// Add an auth header to every request this program makes.
$util.onRequest(req => ({ headers: { Authorization: "Bearer " + $token } }))

// Refresh the token once on a 401, then replay the request.
$util.onResponse(async (res, retry) =>
  res.status === 401 ? (await refreshToken(), retry()) : res)

onRequest returns a partial that is merged over the outgoing request (headers are shallow‑merged). onResponse receives a one‑shot retry() and may return a replacement response or nothing to pass the original through. Request interceptors run host → program; response interceptors run program → host.

Cached reads with $query

$query takes the same config as $http but deduplicates and caches. Calls that share a key (or the same derived method + url + query + body) share one in‑flight request and one reactive bag — so the same data read from several components hits the network once.

$users = $query({ url: API + "/users", key: "users", ttl: 30000 })
// Elsewhere — no second request, same bag:
$alsoUsers = $query({ url: API + "/users" })
OptionTypePurpose
keystringExplicit cache key. Calls with the same key share a bag. Never sent to fetch.
ttlnumberMilliseconds. Auto‑refetch when cached data is older than this.
refetchIntervalnumberPoll: re‑issue every N ms.
refetchOnFocusbooleanRefetch when the tab regains focus.
refetchOnReconnectbooleanRefetch when the browser comes back online.
// A live dashboard tile that refreshes itself.
$stats = $query({
  url: API + "/stats",
  key: "stats",
  refetchInterval: 15000,
  refetchOnFocus: true
})

Infinite & paginated lists

Pass an infinite config to turn a $query into a paginated list. .data flattens every loaded page; call .loadMore() to append the next one and read .hasMore / .loadingMore to drive the UI.

$feed = $query({
  url: API + "/posts",
  infinite: {
    param: "page",        // query param name (default "page")
    limit: 20,            // page size (default 20)
    mode: "page",         // "page" | "offset"
    select: b => b.results // pluck the array out of a wrapped response
  }
})

$app(Column([
  Column($feed.data.map(p => Card([Text(p.title)]))),
  $feed.hasMore
    ? Button($feed.loadingMore ? "Loading…" : "Load more", { onClick: () => $feed.loadMore() })
    : Text("That's everything.")
]))

Mutations

$mutation is the deferred‑write variant: it does nothing until you call .mutate(overrides?). Method defaults to POST. On top of plain writes it supports optimistic updates (apply state instantly, auto‑rollback on failure) and cache invalidation (refetch dependent $querys).

$addTodo = $mutation({
  url: API + "/todos",
  optimistic: o => { $todos = [...$todos, o.body] },  // instant; rolls back if the request fails
  invalidates: ["todos"]                              // refetch any $query whose key contains "todos"
})

Button("Add", { onClick: () => $addTodo.mutate({ body: { title: $title } }) })

Read .loading / .error / .data on the bag, and assign an .onDone settle hook. Call $util.invalidate(keys) any time to refetch matching queries after a manual write.

GraphQL

Add a gql document (and optional variables) to any $http / $query / $mutation. The request becomes a POST of { query, variables }, .data is unwrapped from the GraphQL data field, and a GraphQL errors array surfaces through .error.

$viewer = $query({
  url: "https://api.example.com/graphql",
  key: "viewer",
  gql: `query($login: String!) { user(login: $login) { name avatarUrl } }`,
  variables: { login: $handle }
})
// $viewer.data.user.name

Realtime — $socket & $sse

For push data, open a reactive WebSocket with $socket or a Server‑Sent‑Events stream with $sse. Both return a bag whose .status / .last / .messages fields re‑render as frames arrive (string payloads are JSON‑parsed when possible), and both are torn down automatically on re‑plan or disconnect — a program never leaks a live connection.

// Two-way WebSocket with auto-reconnect
$chat = $socket({ url: "wss://example.com/room/42", reconnect: true })
Badge($chat.status, { tone: $chat.status == "open" ? "success" : "warning" })
Button("Send", { onClick: () => $chat.send({ text: $draft }) })
// $chat.status · $chat.last · $chat.messages · $chat.attempts

// One-way Server-Sent Events
$prices = $sse({ url: API + "/prices", event: "tick" })
Text($prices.last ? $prices.last.value : "—")
Member$socket$sse
.status ("connecting" | "open" | "closed") · .connected · .last · .messages · .error
.send(data)✓ (objects JSON‑stringified; queues while connecting and flushes on open)
reconnect: true | n config + .attempts✓ (exponential backoff, 500 ms → 15 s cap; a user close() always stops)native (EventSource retries on its own)
.close()✓ (disables auto‑reconnect)

Rules of thumb

Next