Skip to content

Async work

Three tools, and the choice is mostly about who starts the work and whether it can be cancelled.

Runs Started by Cancellable
Callback<A, R> synchronously you call it
Action<A, R> async you call it yes
Resource<T> async its dependencies refetches

A Callback<A, R> is a function that can read and write reactive state.

let add = Callback::new(move |n: i32| {
*state.count().write() += n;
});
add.call(5);

It is Copy, so it can be stored in context and used from anywhere inside the pier.

#[derive(Clone, Copy)]
struct AddCount(Callback<i32, ()>);
// in run_pier
provide_context(AddCount(add));
// in run_tell
let AddCount(add) = use_context::<AddCount>().unwrap();
add.call(n);

That pattern (a callback in context, invoked by a tell) is how you keep a rule in one place instead of repeating it in every runner that needs it.

An Action<A, R> is a callback that awaits, and can be cancelled.

let ticker: Action<i32, ()> = Action::new(move |step| async move {
loop {
sleep(1_000).await;
*state.count().write() += step;
}
});
ticker.call(1); // start
ticker.cancel(); // stop

It also tracks its own state:

ticker.pending(); // running
ticker.ready(); // finished

An action is the right answer when a tell needs to start something slow. The tell returns immediately, the action keeps working, and the hails watching the state it mutates update on their own.

A Resource<T> is an async value that refetches when its dependencies change. You do not call it.

let profile: Resource<User> = Resource::new(move || async move {
let id = *user_id.read(); // dependency
fetch_user(id).await
});

Change user_id, and the resource fetches again.

Reading gives an Option<T> (None while the first fetch is in flight):

match &*profile.read() {
Some(user) => { /* ... */ }
None => { /* loading */ }
}

Wire it to JS with set_read_hail, and declare the ret as Option<T> so the loading state arrives as undefined:

#[ret(Option<User>)]
Profile,
Hail::Profile => resource.set_read_hail::<Converter>(),
const profile = pier.readHail("Profile"); // User | undefined

Both async tools in one place. The resource multiplies count by ten, after a deliberate 600ms wait. The ticker is an action: +1 every second until stopped.

Press +1 and watch the order of events: count updates immediately, the resource marks itself pending, and the new value lands when the wait is over. Nothing subscribes it to count. Reading the value inside the fetch is what makes it follow.

Resource.tsx
import { usePier } from "../../setup/solid/bridge";
export default function ResourceDemo() {
const pier = usePier();
const count = pier.readHail("Count");
const tenTimes = pier.readHail("TenTimes"); // number | undefined
const loading = pier.readHail("TenTimesLoading");
const running = pier.readHail("TickerRunning"); // Action state
return (
<div class="demo">
<p>
count: <b id="count">{count()}</b> · ×10 (async):{" "}
<b id="ten-times">{tenTimes() ?? ""}</b>{" "}
<span id="loading">{loading() ? "(fetching…)" : ""}</span>
</p>
<button id="bump-1" onClick={() => pier.tell({ Bump: 1 })}>
+1
</button>
<button id="start" onClick={() => pier.tell({ StartTicker: 1 })}>
start ticker (+1/s)
</button>
<button id="stop" onClick={() => pier.tell("StopTicker")}>
stop
</button>
<span id="running">{running() ? " ticking…" : ""}</span>
</div>
);
}

Running now, in your browser

Three things worth trying:

  • Press +1 several times quickly. Only the last fetch survives: each run cancels the one before it, so an older result never overwrites a newer one.
  • Start the ticker. Every tick moves count, and the resource refetches each time. Nobody wired those together; they both just depend on count.
  • Stop it. The count freezes exactly where it is, and ticking… disappears. That indicator is a memo over the action’s own pending() state.
  • A synchronous rule you want to reuse → Callback
  • Something slow that a user starts or stops → Action
  • Data that follows a value → Resource

All of these belong to a scope. Sphere and lifetime is that scope.