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
| Key | Type | Notes |
|---|---|---|
url | string | Required. A full absolute URL, e.g. "https://api.example.com/todos". |
method | string | "GET" (default), "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS". |
query | object | Serialised 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. |
headers | object | Plain object of request headers. |
body | any | Request 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. |
…rest | fetch options | Any 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:
- Imperatively — call
$todos.refetch()(e.g. from a refresh button or after a mutation). - Reactively — wrap the call in an
effectwith the dependencies that should trigger a new request.
// 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" })
| Option | Type | Purpose |
|---|---|---|
key | string | Explicit cache key. Calls with the same key share a bag. Never sent to fetch. |
ttl | number | Milliseconds. Auto‑refetch when cached data is older than this. |
refetchInterval | number | Poll: re‑issue every N ms. |
refetchOnFocus | boolean | Refetch when the tab regains focus. |
refetchOnReconnect | boolean | Refetch 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
- Always pass a full absolute URL. There is no base URL.
GETis the default — omitmethodfor reads.- Use
queryfor querystrings; never hand-concatenate?a=1&b=2. - Object
bodyis JSON-encoded for you; don’tJSON.stringifyit yourself. - Re-run via
.refetch()or aneffect— a request does not auto-refetch when a read changes. - Render the four states with
Asyncrather than hand-rolledifchains.
Next
Actions
Fire mutations, snapshot/rollback, and wire handlers to buttons.
Read the guide → ReactivitySide effects
Re-run requests on a schedule or when a dependency changes.
Read the guide → ShowcaseLive demos
See the Todos CRUD app and other examples running in the browser.
Browse demos →