Effects, HTTP, and the Browser

A Signals app describes external work with commands. The host executes those commands and returns results through signals. HTTP responses, timer ticks, and browser changes use the same propagation engine as UI events.

The API separates inputs from requests to do work:

  • Sources — values the host owns and pushes into your graph: task results, timer ticks, the current URL, online status, storage values.
  • Commands — descriptors your tree emits that ask the host to do something: start a request, navigate, set the title, write to storage.

Tasks

A task represents an asynchronous result with a Loading / Done / Failed lifecycle. Its name is useful in tests and diagnostics; it is not a global ID. Create a task, fold its status into your own type, and start it with a command. For example, given Status : [Loading, Ready(Str), Failed(Str)]:

task = Http.get_text_task("dashboard")

status : Signal.Signal(Status)
status = Signal.fold_task(task, Loading, |body| Ready(body), |err| Failed(err))

Signal.fold_task(task, loading_value, on_done, on_failed) gives you a signal that you can map and render like any other signal.

Include a lifecycle sink in the returned element tree to start it on mount:

Ui.on_mount(|| Http.get_text(task, "/api/dashboard"))

Starting another request for the same task source in the same owning scope cancels its pending request. Late results from the cancelled request are ignored. Independent requests need independent task sources or owning scopes. Disposing the request’s scope also cancels it.

Cancellation prevents stale results from updating the graph. It does not undo work a server may already have performed, such as saving an article.

A request belongs to the scope of the action or lifecycle sink that starts it. Keeping its task-result signal in a longer-lived scope does not extend the request’s lifetime. In particular, if the new Loading value removes the action’s own Ui.when branch, that request is canceled in the same turn. Keep the request-starting action in a scope that survives Loading when the request must continue; disabling its button need not remove that scope.

Deterministic tasks for tests

Signal.cancel(task) cancels its active request and publishes the task constructor’s typed cancellation error through ordinary propagation. Canceling a source with no pending request does nothing. A canceled request’s late result is ignored, and a later start gets a fresh request ID. HTTP tasks publish Canceled; the string-based fake task model passes "canceled" to its error decoder. Cancellation cannot reverse external work that already committed. Native host capacity refusal publishes the constructor’s declared resource error and supersedes any older request for that source; it does not start new host work.

Signal.fake_task(name, on_done, on_failed) creates a task the native test runner drives directly:

task = Signal.fake_task("lookup", |value| value, |err| err)
Ui.on_mount(|| Signal.start_str(task, "query"))
(expect-pending-task "lookup" 1)
(resolve-task "lookup" "3 results")

Each resolution or rejection needs a pending request. In a separate test, use reject-task to exercise the failure path. Native specs can also drive HTTP tasks; see testing for their response payloads.

HTTP

Choose based on whether you need response metadata:

Text-only, for simple GETs:

task = Http.get_text_task("dashboard")
Ui.on_mount(|| Http.get_text(task, "/api/dashboard"))

Full request/response, for everything else — methods, headers, bodies, timeouts, and status codes:

get_request : Str -> _
get_request = |uri|
    Http.with_timeout_ms(
        Http.with_headers(
            Http.request_from_method(Http.method_get).with_uri(uri),
            [{ name: "accept", value: "application/json" }],
        ),
        8000,
    )

task = Http.request_task("articles")
Ui.on_mount(|| Http.start(task, get_request("/api/articles")))

Requests and responses use the pinned roc-lang/http package types through thin platform wrappers: Http.method_*, Http.request_from_method, Http.with_uri, Http.with_body, Http.with_headers, Http.add_header, Http.with_timeout_ms, and the Http.response_* accessors.

Handling responses

Http.request_task returns a response for HTTP status codes including 404 and 422. Inspect Http.response_status(response) and decode the body according to your endpoint’s contract. A validation response may need field errors; a missing resource may need a different page.

Transport and boundary failures use Http.HttpError: Network(Str), Timeout, Canceled, ResourceLimit(Str), Unsupported(Str), or ResponseMaterialization(Str). Http.error_text supplies a display string when you do not need to distinguish those cases.

The text helper discards response status and headers. It also substitutes replacement characters for invalid UTF-8. Use the full response API when status codes matter or when decoding must reject invalid text:

body = Str.from_utf8(Http.response_body(response))

Handle that Try before parsing structured data. Avoid lossy conversion on a path where changing the input text could change its meaning.

Fold decoded responses into a domain type such as [Loading, Ready(Articles), Failed(Str)]. An exhaustive match covers those variants, but an empty list inside Ready still needs an explicit UI decision.

JSON

Use Roc’s builtin Json with a declared record type. For an endpoint that returns an article envelope:

parse : Str -> Try({ article : Article }, [InvalidJson(Str), MissingRequiredField(Str)])
parse = Json.parser_camel()

Json.parser_camel() maps camelCase JSON fields to snake_case Roc fields. Json.to_str(value) encodes. See Conduit’s Api.roc for endpoint-specific decoding. That example still contains shield_escapes and restore_text, a workaround for an upstream JSON escape limitation. It is not a general JSON decoder: it does not cover all legal escaped input. Before adapting that code, test representative payloads against your pinned compiler, including quotes, backslashes, newlines, and Unicode escapes. Keep any necessary compiler workaround separate from your domain mapping.

What the browser does

The runtime passes method, headers, body, and an abort signal to fetch, and implements the request timeout around that call. It does not set credentials, redirect, mode, cache, or referrer policy — browser defaults apply, meaning same-origin credentials, followed redirects, and normal CORS. A CORS denial or DNS failure arrives as Http.Network(message).

Roc request headers use { name, value } records. Browser fetch and Headers processing still apply, so do not rely on receiving original wire header order or duplicate header spelling.

You can intercept tasks entirely in JavaScript by passing a taskHandler to mountSignalsApp. The site uses handlers for deterministic example data, including Conduit’s in-page backend in www/static/conduit_backend.mjs. A handler can leave a request unhandled to let the runtime use its normal task bridge. This is useful for demos and browser tests; it does not establish compatibility with a real server.

Lifecycle sinks

These are Elem nodes that render nothing and register lifecycle behavior:

SinkRuns
Ui.on_mount(to_cmd)once, when the owning scope mounts
Ui.on_change(signal, to_cmd)after a changed value settles, excluding the initial value
Ui.on_change_initial(signal, to_cmd)on the first mounted value and on changes
Ui.on_cleanup(cleanup)when the owning scope is disposed

on_change versus on_change_initial matters for deep links: use on_change_initial when the very first value must take effect, such as setting the document title from the URL on a cold load.

Include them as children of the scope that should own the work. This fragment assumes remote, status_text, task, and path are already defined:

Html.section_c(
    "Feed",
    "grid gap-3",
    [
        Html.paragraph_s(remote.map(status_text)),
        Ui.on_mount(|| Http.start(task, get_request("/api/articles"))),
        Ui.on_change(path, |value| Http.start(task, get_request("/api/articles?path=${value}"))),
        Ui.on_cleanup(Signal.cleanup("feed cleanup")),
    ],
)

Signal.noop is a command that does nothing — useful when a branch should not act:

Ui.on_change(
    poll_ok,
    |ok| if ok { Http.start(task, request) } else { Signal.noop },
)

Requests follow from state

For data that should follow filters, pagination, or another value, derive a request description and let changes trigger the fetch:

request = { page: page, tag: tag, token: token }.Signal
Ui.on_change_initial(request, |value| Http.start(task, feed_request(value)))

This also fetches for the first mounted value. Use on_change if some other part of the scope already starts the initial request. Equality pruning means an unchanged request description does not start another request.

For explicit user actions such as retry, submit, refresh, or favorite, use an action:

Html.button(
    "Refresh",
    Ui.action(request, |value| Http.start(task, feed_request(value))),
)

Every accepted click runs the command, even with identical reads. A response that changes one of the reads does not start another request. Conduit’s article favorite button uses this pattern; it needs no serial counter or special equality.

Timers

ticks = Signal.interval(5000)
Ui.on_change(ticks, |_| Http.get_text(task, "/api/dashboard"))

Signal.interval(period_ms) starts at zero and counts ticks while its scope is mounted. The example fetches on each tick; add a mount hook if it should fetch immediately too.

To stop polling while a page is hidden or offline, put the interval and its request-starting hook inside a Ui.when branch selected by visibility and online status. Leaving the branch stops the interval and cancels its requests. Combining visibility with another signal does not, by itself, stop a timer.

See the status-page example for polling controlled by page visibility.

Browser environment

Environment values are signals seeded before the first render. Route selection and session rendering can use their initial values during mount.

SourceType
Browser.entropy_seed()Signal(U32) — one seed per mount
Browser.location()Signal(Location) — { path, query, hash }
Browser.visibility()Signal([Visible, Hidden])
Browser.online()Signal(Bool)
Browser.local_storage_text(key)Signal(StorageText)
Browser.session_storage_text(key)Signal(StorageText)

entropy_seed() is intended for initializing a pure PRNG. The browser samples it once with crypto.getRandomValues, while native specs receive a stable seed for reproducibility. The returned number is not itself suitable for secrets, tokens, or other security decisions.

And the matching commands: Browser.push_state, Browser.replace_state, Browser.set_title, Browser.set_local_storage_text, Browser.set_session_storage_text, Browser.remove_local_storage, Browser.remove_session_storage.

Location is deliberately raw: path keeps its leading /, while query and hash omit ? and #. Parsing is your job.

Routing

Parse the location signal into an application route type:

location : Signal.Signal(Browser.Location)
location = Browser.location()

route = location.map(Route.from_location)
title = route.map(Route.title)

Render with Ui.switch over a route or route kind, or Ui.when for a boolean choice. Intercept in-app link clicks with event_policy_prevent_default and emit history commands. Conduit routes its link messages through a navigation intent state; its shell includes these hooks:

Ui.on_change(route_intent.signal(), |intent| Browser.push_state(Nav.location(intent))),
Ui.on_change_initial(title, Browser.set_title),

Auth guards are the same shape — derive a redirect decision and emit replace_state:

Ui.on_change_initial(
    guard,
    |target|
        match target {
            Redirect(location) => Browser.replace_state(location)
            Stay => Signal.noop
        },
)

See Conduit’s Route.roc and Nav.roc for parsing, link construction, and hash-route handling.

Static hosting note. Conduit uses hash-style routes (#/article/slug) because GitHub Pages has no SPA fallback — every deep link must resolve to one real HTML file. With a server that can rewrite unknown paths to index.html, use clean history paths instead. Both work; the choice is about hosting.

Storage

Storage reads are declared keys, resolved before first render:

saved : Signal.Signal(Browser.StorageText)
saved = Browser.local_storage_text("app.draft")

StorageText is StorageMissing, StorageValue(text), or StorageUnavailable(message). Handle all three: access can fail because of browser policy or the environment, and a missing value is different from an unreadable store.

Writes are commands:

Ui.on_change(draft, |text| Browser.set_local_storage_text("app.draft", text))

Namespace keys such as app.draft because several apps can share an origin. Write failures surface as host diagnostics, not command return values. If the UI needs to observe what was stored, declare the corresponding storage source and handle its result. Do not treat emitting a write command as proof of a successful save.

Conduit derives its local session from declared JWT and username storage signals. The server must still validate the token on authenticated requests.

Cleanup

Cleanup is mostly automatic. Disposing a scope cancels its timers and requests and releases its retained closures. Ui.on_cleanup(Signal.cleanup("name")) registers a named cleanup that native specs can assert on:

(expect-cleanup "live search panel cleanup" 1)

Pair this assertion with pending-task and interval assertions when checking that closing a panel stops its work.

JavaScript widgets

The implemented integration API uses Html.behavior(name) and a behaviors registry supplied at mount. For a chart, mark an element:

Html.div([Html.behavior("traffic-chart"), Html.test_id("chart")], [])
await mountSignalsApp({
  wasmUrl: "./app.wasm",
  root,
  behaviors: {
    "traffic-chart": {
      // Called when the element is attached. Return a cleanup function.
      attach(el, { runtime }) {
        draw(el);
        return () => teardown(el);
      },
      // Called when ONE dynamic custom attribute changes. `attrName` is its name.
      update(el, attrName, { runtime }) {
        if (attrName === "data-points") draw(el);
      },
    },
  },
});

attach receives the element and returns its cleanup function. update receives one changed attribute name, so read the relevant attribute from the element. The example assumes your JavaScript defines draw and teardown.

A missing registration or missing attach function currently emits behavior_missing telemetry and skips attachment. Check telemetry if the element appears but its integration does not start. A working example lives in www/static/service_ops_charts.mjs.

update fires only for dynamic custom attributes — Html.attr_s and Html.attr_maybe_s — not for fixed fields like text, class, value, or checked.

Return cleanup that releases listeners, subscriptions, and resources created by attach. The runtime calls it when the element is removed or the mount is torn down. Send values back through a declared custom event: dispatch a CustomEvent with text detail and bind it with Html.on_custom and State.on_detail.

The typed widget and subscription vocabulary in design.md describes the target architecture. It is not an additional implemented API to import today.

Next

Structuring an App covers module boundaries, state lifetimes, and request ownership.