Events and Rust

A template describes how an interface responds to its state. An event handler gives the user a way to change that state: advance a task, edit a title, choose a destination, or move something across the screen.

This chapter continues the Field notes panel from Properties. We will look closely at its button handler, add a title editor and a keyboard shortcut, then explore lifecycle and custom interactions. The final sections cover application setup and ownership and asynchronous work with Tokio. The examples use the same Notes component and its title and progress properties; the complete starting declaration and template are in Properties. Keep use pax_kit::*; in the Rust file.

Space Game combines keyboard input with a running simulation. Use w, a, s, and d to move and Space to fire. In an embed, first click the game area so keyboard input reaches its document. After game-over, choose Play Again below the score to start a new round. Its source combines key state with tick-driven updates; the frame-based movement is specific to this example.

Connect an action to Rust

The panel's button names the method to call when it is activated:

<Button x=24px y=76px width=160px height=28px
    label="Advance" @button_click=self.advance
/>

The corresponding method lives in impl Notes:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn advance(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        let next = (self.progress.get() + 0.25).min(1.0);
        self.progress.set(next);
    }
}
}

The binding @button_click=self.advance connects the Button's activation event to a public Rust method. The method receives three things:

  • &mut self gives it mutable access to the Notes instance that owns this template.
  • &NodeContext describes the node handling the event and provides runtime information and operations, such as its bounds and navigation.
  • Event<ButtonClick> carries the event. This particular payload has no fields; the activation itself is enough to advance progress.

The leading underscores in _ctx and _event are Rust's convention for arguments that this method does not use. Other handlers can name them ctx and event and read their contents.

The handler calculates a new value and writes it to the property. The label and progress bar already read that property, so their bindings pick up the change. See Properties for the state operations and PAXEL for the formulas that consume them.

Buttons and custom activation

Use @button_click with the native Button control. For custom content, such as a card or drawing, @click and @tap provide general activation handlers. Both receive Event<Click>, which includes position, button, and modifier information through event.mouse.

Either binding on its own receives mouse clicks and single-touch taps. If both are present on the same node, mouse input selects @click and touch input selects @tap. This lets a custom surface share an action across input types, while still allowing separate actions where needed.

A touch activation occurs at release, after TouchEnd, when the gesture qualifies as a tap. For immediate pressed feedback or dragging, use the lower-level mouse and touch events described under Events and coordinates.

Use event data

An event's type tells you what data the handler receives. These are common starting points:

BindingHandler argumentUseful data
@button_clickEvent<ButtonClick>Activation of a native Button
@click, @tapEvent<Click>event.mouse.x, .y, .button, .modifiers
@mouse_moveEvent<MouseMove>Pointer position through event.mouse
@touch_start, @touch_move, @touch_end, @touch_cancelEvent<TouchStart>, Event<TouchMove>, Event<TouchEnd>, Event<TouchCancel>event.touches, including identifiers and positions
@key_down, @key_upEvent<KeyDown>, Event<KeyUp>event.keyboard.key, .modifiers, .is_repeat
@textbox_input, @textbox_changeEvent<TextboxInput>, Event<TextboxChange>event.text
@checkbox_changeEvent<CheckboxChange>event.checked
@slider_changeEvent<SliderChange>event.value
@viewport_proximity_enterEvent<ViewportProximityEnter>Initial nearby current sample
@viewport_proximity_changeEvent<ViewportProximityChange>previous and current geometry
@viewport_proximity_exitEvent<ViewportProximityExit>Last inside and terminal outside samples

Event<T> exposes its payload fields directly, so event.text is a convenient way to read event.args.text. The event API reference lists the complete payloads, including wheel, drop, and other input events. Sensor availability and native control behavior also depend on the target.

To edit the panel's title, add this Textbox after the panel's outer Group in lib.pax:

<Textbox x=24px y=260px width=320px height=32px
    text={self.title} @textbox_input=self.rename
/>

Add its handler to the Rust file:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn rename(&mut self, _ctx: &NodeContext, event: Event<TextboxInput>) {
        self.title.set(event.text.clone());
    }
}
}

As the user types, the handler copies the event's text into title, and the panel's heading follows. The template supplies the current title to the Textbox; the handler makes the application's response to edits explicit.

On web, @textbox_input follows the browser's input event, while @textbox_change follows its change event, used for committed edits. Choose input for live feedback; choose change when the action should wait for a commit. Exact commit timing belongs to the platform control, so test it on the targets you ship. Forms, two-way bindings, and focus are covered in Native Controls.

Choose the binding's scope

An inline binding belongs to the element where it is written. In the Advance example, the Button is the receiving node, and self is the containing Notes component. Consequently, ctx.bounds_self describes the Button's bounds, even though self.progress is a field on Notes.

A binding in the component's @settings block runs at component scope. For example, to handle button activations that reach Notes, move the binding off the Button and into the template's settings:

@settings {
    @button_click: advance
}

Here both self and the receiving context refer to Notes. Activations from its buttons can reach this handler through event propagation. If you try this alternative, remove @button_click=self.advance from the Button: keeping both bindings calls advance at both places and increments twice.

The same distinction applies when you use a child component. A handler in the child's own settings acts on that child; an inline binding on the child's invocation acts on the containing component. See Components and Composition for the component boundary and Event delivery for propagation.

Do application work

Handlers are ordinary Rust methods. They can validate input, call helpers, update several properties, and call ordinary Rust dependencies. Keep reusable work in a helper when more than one interaction needs it.

For example, let the right arrow key advance the panel as well as the button. Replace the original impl Notes containing advance with:

#![allow(unused)]
fn main() {
impl Notes {
    fn advance_progress(&mut self) {
        let next = (self.progress.get() + 0.25).min(1.0);
        self.progress.set(next);
    }

    pub fn advance(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        self.advance_progress();
    }

    pub fn key_down(&mut self, _ctx: &NodeContext, event: Event<KeyDown>) {
        if event.keyboard.key == "ArrowRight" && !event.keyboard.is_repeat {
            self.advance_progress();
            event.prevent_default();
        }
    }
}
}

Keep rename and any other methods in their existing impl blocks. Add the keyboard binding to the template's @settings block, or create that block if there is none:

@settings {
    @key_down: key_down
}

The is_repeat check limits this action to the initial key press. On web, try it while focus is outside the Textbox and other native controls. The keyboard dispatch and default-prevention boundaries are explained below.

Pax calls event handlers synchronously. Keep their work short enough for the interface to remain responsive. For network requests or lengthy work, start a scoped task through an explicit executor. Shared Property handles can receive worker publications; expressions and UI callbacks stay on the UI thread. Declaring a handler async fn produces a compile-time diagnostic: Pax does not implicitly await a handler's return value.

A loading interface can expose a loading flag, a result, and an error as properties. Its template can then describe those states while the application layer manages the request. See Application setup and Asynchronous work below. For other common actions, read Routing for navigation and Motion for easing and timeline control.

Work with component lifecycle

Some work follows the lifetime of a component rather than an input event. Pax recognizes public lifecycle methods on its Rust implementation:

MethodWhen it runsTypical use
on_mountWhen the component mountsInstall computations or subscriptions that need established bindings or context
on_tickOn runtime ticks while the component is activeAdvance an application simulation or other time-driven logic
on_pre_renderAfter tick handlers, in the pre-render phaseSynchronize work that must follow that tick's updates
on_unmountWhen the component is removed from the mounted treeRelease application-owned registrations or finish mounted work

Lifecycle methods take &mut self and &NodeContext, without an Event argument. For a small way to observe the panel's lifetime, add:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn on_mount(&mut self, _ctx: &NodeContext) {
        log::info!("Notes mounted");
    }

    pub fn on_unmount(&mut self, _ctx: &NodeContext) {
        log::info!("Notes unmounted");
    }
}
}

On web, open the app with ?pax_log=info to see these messages in the browser console. The unmount message appears when Pax removes the component from the tree, for example through a template conditional. It is not a guarantee of cleanup on browser or process termination.

If Notes already has an on_mount, add the new work to that method. The computed-property example uses mount to establish a derived label. Ordinary initial values can live in Default; mount is useful for setup that needs the mounted context or property connections. Do not assume that all children have finished mounting or measuring when the parent's mount handler runs.

The short names mount, tick, pre_render, and unmount are also recognized. When both forms exist, the on_* name is selected. To use a different method name, bind it explicitly, for example @mount: initialize in @settings, with a public initialize(&mut self, ctx: &NodeContext) method. An explicit binding replaces automatic selection for that lifecycle event.

Lifecycle bindings also work inline on a child element:

<Group width=100% height=120px @mount=self.panel_mount>
    <Text text={self.title} width=100% height=100% />
</Group>
#![allow(unused)]
fn main() {
impl Notes {
    pub fn panel_mount(&mut self, ctx: &NodeContext) {
        log::info!("{}: panel mounted at {:?}", self.title.get(), ctx.bounds_self.get());
    }
}
}

For inline @mount, @tick, @pre_render, and @unmount, self is the specific component instance that authored the binding (Notes here). ctx describes the child receiving the call (Group here), including its bounds, tree identity, expression stack, and subscription lifetime. Moving that child to a different rendering parent does not change the handler's Rust state owner. Each instance keeps its own state. Reactive bindings created by the handler belong to that authored owner; subscriptions and async work started through ctx retain the child's lifetime.

A child's own lifecycle methods or @settings bindings still run on the child's state. An inline binding on its invocation can run alongside them; it does not replace them or bubble to ancestors. Suspended nodes skip tick and pre-render handlers, but still run mount and unmount handlers when their mounted lifetime changes. These ownership rules apply in debug and release. Component mask-source subtrees have a mounted logical lifetime and follow the same lifecycle ownership rules, even though their output is used as a mask rather than presented directly.

on_tick is driven by the runtime, with no fixed frame-rate guarantee. Use elapsed time for behavior that should progress at a stable real-time rate; ctx.elapsed_frames and ctx.elapsed_millis expose the runtime's clocks. For ordinary visual transitions, start with the facilities in Motion.

Register subtree stores in the provider component's own mount handler with ctx.provide_store(...). The context's node owns the registration, even when an inline handler's Rust self belongs to another component. Descendants can look up a parent's store once that parent has published it during mount.

Final unmount clears the node's store registrations after unmount handlers run, so those handlers can still access the departing scope. A later mount starts fresh state; a saved context cannot access that new mount's stores. Exit transitions can delay final unmount, as described in conditional content.

Subscriptions registered through NodeContext::subscribe are automatically cleared when their node unmounts. Other resources owned by your application still need the cleanup appropriate to that integration. See Subscriptions and effects for callback scheduling and lifetime details.

Viewport proximity is opt-in

Use explicit element bindings for @viewport_proximity_enter, @viewport_proximity_change, and @viewport_proximity_exit. These use the three-argument Event<T> handler form; naming a method on_viewport_proximity_enter does not automatically subscribe it. The observed element can be a Group, but the reference remains the global viewport.

Events are local, do not bubble, and have no cancellable default. Initially nearby content receives enter followed by change. Unmount does not synthesize exit, so resource-owning components also need their normal unmount cleanup. Observe viewport proximity explains snapshots, clipping, fixed margins, and a complete animation policy.

Dispatch custom events

A reusable control can report an application-level action such as “advance,” “save,” or “dismiss.” Its caller chooses how to respond. Use NodeContext::dispatch_event to connect that named action to a handler on the caller.

Consider an Advance control whose internal Button should ask Notes to update progress. Add this declaration to lib.rs, keeping the existing use pax_kit::*; import:

#![allow(unused)]
fn main() {
#[pax]
#[inlined(
    <Button width=100% height=100% label="Advance"
        @button_click=self.activate
    />
)]
pub struct AdvanceButton {}

impl AdvanceButton {
    pub fn activate(&mut self, ctx: &NodeContext, _event: Event<ButtonClick>) {
        ctx.dispatch_event("advance")
            .expect("AdvanceButton requires an @advance binding");
    }
}
}

#[inlined(...)] associates this short template with AdvanceButton. The native Button calls activate on that component. Its handler then dispatches the custom name "advance". The name is a string literal; the method accepts an &'static str. Choose a name for the action your component offers, keeping it distinct from built-in input event names.

Replace the panel's original Button in lib.pax with:

<AdvanceButton x=24px y=76px width=160px height=28px
    @advance=self.advance_from_control
/>

Add the receiver to Notes:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn advance_from_control(&mut self, _ctx: &NodeContext) {
        self.progress.set((self.progress.get() + 0.25).min(1.0));
    }
}
}

The @advance binding on the component invocation registers that receiver. Pressing the Button calls AdvanceButton::activate, which queues advance; Pax then calls Notes::advance_from_control. The two handlers belong to different component instances. The control needs no access to Notes' fields, and the caller can reuse the control with another response elsewhere.

Context and receiver

The context passed to dispatch_event identifies the emitting component. Here ctx is the internal Button's context, whose containing component is AdvanceButton. Pax looks for advance on that component's invocation. In the receiver, self is Notes while the context describes the AdvanceButton invocation, following the inline-binding scope described above.

This mechanism connects a component to its caller. It does not search the tree for a matching name or broadcast the action to every ancestor. Use the context supplied to the emitting handler and connect the action explicitly at the call site. A root component has no outer caller to receive such an action.

Bind custom event names inline, as @advance=self.advance_from_control. Custom names are not currently supported in an @settings handler declaration. That restriction is separate from the built-in bindings, such as @button_click, that settings blocks do support.

Delivery and data

dispatch_event returns Result<(), String>. It checks that the named receiver is registered before adding the event to the runtime's queue. Ok(()) means the event was queued; the receiving handler has not run when the call returns. Delivery happens in the custom-event phase at the end of a runtime tick. Avoid reading state immediately after dispatch under the assumption that the receiver has already updated it.

The example uses expect because the control requires an @advance binding. If the action is optional, handle the returned error according to that component's contract. A missing receiver does not create a new event binding.

Custom callbacks have two arguments, &mut self and &NodeContext, with no Event<T> payload. This API sends a name only: it has no argument for custom data, no return value from the receiver, and no input event to cancel. Use properties or shared state for data the receiver needs, and remember that a queued receiver observes that state at delivery time. If several queued actions each need distinct data, the application must preserve those values explicitly rather than overwrite a single shared field.

The original Button event still follows its ordinary delivery rules. Dispatching advance neither cancels that input nor stops its propagation. If the same application action is also bound to a bubbling @button_click, both paths can run it. Use the custom action as the control's outward interface and keep the native activation handler inside the control.

For split-file components, value inputs, bind:, and scoped stores, continue with Components and Composition.

Event delivery

The remaining sections are useful when interactions span several elements, share shortcuts, or depend on pointer geometry.

Targeting and propagation

Pointer input begins with hit testing: Pax finds the topmost eligible node under the pointer. Remember that earlier siblings in a Pax template are drawn above later siblings; see Templates. Native controls can also send events directly to their corresponding node, as a Button does for @button_click.

Common pointer and control events then travel from the receiving node through its template parents. This is event bubbling. Each visited node runs its applicable handlers with its own context, which is why an inline handler and a component-level handler can both observe one action.

The route follows template ancestry. Projection and component composition can make that ancestry different from the visual container arrangement. Do not infer the entire event path from which rectangles overlap on screen.

Not every event family bubbles. For example, @mouse_over and @mouse_out dispatch locally, while keyboard events use the global delivery described below. The click/tap choice described earlier is made at each node visited during activation propagation.

Default actions

event.prevent_default() requests that the chassis suppress the platform's default response. The arrow-key handler uses it to request that the browser not perform its normal arrow-key action for that handled press.

Default prevention leaves Pax's handler delivery intact: other handlers and template parents still receive the event. The current Event API has no stop_propagation method.

Whether the request can suppress an action depends on the chassis and event. On web, it also depends on the browser listener's cancellation rules; the current touch-start, touch-end, and touch-cancel listeners are passive. Do not rely on default prevention as a portable way to take ownership of a gesture or prevent a native control from changing its value.

Keyboard delivery

The global @key_down, @key_up, and @key_press event bridge is implemented on web, macOS, and iOS/iPadOS with a hardware keyboard. The binding and Rust handler shown above work on each of these targets.

Keyboard events currently dispatch across the mounted Pax tree, so multiple components with keyboard handlers may receive the same key. If a shortcut belongs to one active view or mode, check that application state in the handler before acting on it.

On web, the chassis forwards these global keyboard events only while the document body is the active element. When a Textbox or another native DOM element has focus, its keyboard input stays with that control.

On Apple targets, the Pax canvas receives keys as the native first responder. A standalone app initially focuses its canvas. An embedded mount preserves the host's focus; click or tap Pax's rendered content to focus the canvas. Native text controls and the host's own controls retain their normal input paths. Losing focus, deactivating the app, or closing the mount releases held keys through @key_up. A component's @key_down binding does not by itself give that component keyboard focus.

event.keyboard.key carries the typed character, or names such as Enter, Backspace, Delete, Tab, Escape, and ArrowLeft. Modifiers use ModifierKey::{Shift, Control, Alt, Command}. macOS preserves the native repeat flag; on iOS, repeated key-down deliveries before a key-up are marked as repeats. Pax does not synthesize a repeat timer on iOS. Physical keyboard layout and system shortcuts still affect which events the OS delivers.

On Apple targets, calling event.prevent_default() suppresses forwarding that key to the next native responder. Unhandled keys continue through the native responder chain. Cancelling @key_down also suppresses the legacy @key_press event; otherwise it is emitted for printable characters and Enter without Control or Command. Prefer @key_down/@key_up for shortcuts and held-key state. These canvas events do not implement IME composition or summon an iOS software keyboard; use native text controls for platform text entry.

Read Native Controls for the control-specific focus and accessibility model.

Events and coordinates

Mouse and touch positions use Pax's window coordinate space. An interaction inside a translated, scaled, rotated, or scrolling node needs local coordinates. Use ctx.local_point to convert through the node handling the event.

For example, add a pad below the title editor:

<Rectangle x=24px y=320px width=320px height=100px
    fill=rgb(220, 216, 206) corner_radius=12
    @mouse_move=self.track_mouse
/>

Import Point2 alongside the existing pax_kit import and add this handler:

#![allow(unused)]
fn main() {
use pax_kit::math::Point2;

impl Notes {
    pub fn track_mouse(&mut self, ctx: &NodeContext, event: Event<MouseMove>) {
        let local = ctx.local_point(Point2::new(event.mouse.x, event.mouse.y));
        self.progress.set(local.x.clamp(0.0, 1.0));
    }
}
}

Moving across the pad maps its left edge to zero progress and its right edge to one. local_point returns normalized coordinates: (0, 0) is the node's local origin and (1, 1) is its opposite corner. To work in local pixel-like units, read (width, height) from ctx.bounds_self.get() and multiply local.x * width and local.y * height. These are layout units, not a promise of physical display pixels.

The conversion includes the node's transform and ancestor Scroller presentation offsets. Convert inside the handler using its current context; subtracting only the element's x and y misses rotation, scale, and moving scroll content. Read more about Layout and Scrolling.

Touch sequences and capture

For a custom touch interaction, use @touch_start, @touch_move, @touch_end, and @touch_cancel. Each payload contains touches with a position, movement deltas, and an identifier. Retain the identifier your interaction accepts so subsequent events can be matched to it.

The primary touch captures the topmost hit node at TouchStart. While that capture remains valid, subsequent move, end, and cancel events target it even when the finger leaves its bounds; the usual template-parent delivery still applies. This is useful for keeping a drag attached to its original control. It does not establish equivalent mouse capture or a general multi-touch gesture recognizer.

Clear pressed or dragging state in both end and cancel handlers. TouchEnd finishes a normally released sequence. TouchCancel handles an interrupted sequence, such as one aborted by the platform. A cancelled sequence does not synthesize a click or tap. Use the activation event for the final action and the lower-level events for transient feedback when those responsibilities need to stay separate.

Touches inside a Scroller

On iOS and iPadOS, the native Scroller's touch observation lets custom child content receive the start immediately and continue receiving moves while scrolling. Normal release produces TouchEnd; recognition of a scroll disqualifies tap activation without requiring cancellation of that lower-level stream. This allows a child to light up on contact, follow the touch, and clear its feedback when the finger lifts.

A qualifying release emits one tap. The innermost native Scroller owns the touch sequence; ancestor Scrollers do not forward it again. Native controls keep their own action delivery. Bind either @click or @tap for a custom key's activation, and use touch end/cancel only to clear pressed feedback.

Because local conversion includes the moving content transform, a contact that moves with the scroll stays approximately fixed on its original child in that direction. Native controls and platform gestures can impose further input behavior, so test the particular control and target together. On web, tap qualification also uses a movement tolerance; a moved touch sequence need not produce activation on release.

Application setup

Native applications own a normal Rust fn main on macOS, iOS and iPadOS. pax_kit::init!()?.run() prepares the separate UI cartridge and enters the platform host loop in that process. Initialization does not parse application arguments, start development tools, or create an async runtime, even with designtime enabled. Web pages explicitly call Pax.init_web; importing a component library does not launch an app. See entry and mounting for Cargo targets, supplied host views and browser elements.

Inside the library, select an ordinary typed initializer in runnable!:

#![allow(unused)]
fn main() {
use pax_kit::*;

pax_kit::runnable! { root: crate::Notes, init: init_notes }

#[pax]
#[file("lib.pax")]
pub struct Notes { pub title: Property<String> }

fn init_notes(cx: &InitContext) -> AppResult<Notes> {
    let _target = cx.target;
    Ok(Notes { title: Property::new("Field notes".into()) })
}
}

The initializer runs once on the UI owner thread, inside the application's property graph. Pax mounts the returned value itself: it does not serialize it or create another default root. Without init:, the selected root uses Default::default(). Using a component from a dependency does not initialize or launch that dependency's cartridge. Root selection is independent of component location; see runnable declarations.

Keep initialization prompt. Return a real Loading root, stage asynchronous work through an explicit executor, and update its properties when that work completes. Neither returning a Loading root nor awaiting host readiness waits for the data request. Synchronous blocking code in the initializer still blocks the host thread. Target facts are available during initialization; actual viewport bounds are established by mounting.

Dependencies and application lifetime

Dependencies are ordinary Rust fields, constructor inputs or lexical component stores. Mark a field #[pax(rust)] when it owns a Rust value that must stay out of the template value system, reflection and baked program data. Such fields still need the component's usual Clone, Debug and Default behavior; an Option<Rc<Resources>> is useful for resources supplied by the initializer. The field cannot be bound from a template or reconstructed from serialized Pax values. See the Async Workbench source for a complete portable example.

OwnerSuitable stateLifetime
Component propertiesPresentation and interaction stateComponent instance
Lexical local storeDependencies shared within a subtreeProviding node
Initialized root fieldsConfiguration, clients, shared state, executor clientsApplication instance, across child remounts

The root can provide a cloned dependency to descendants with a lexical Store. See component stores and the shared/local property boundary. ctx.application() exposes weak lifetime and cancellation access; it provides no dependency lookup. Cloning it does not keep a closed application active.

Migration: replace #[application(Type)] and its Application implementation with runnable! { root: Root, init: function }. Construct and return the root in that function; replace AppBuilder::provide and AppContext::service with ordinary fields, explicit arguments or lexical stores. Move UI-specific work from started into the root's mount handler. There is no replacement lifecycle-hook trait.

Executor and resource shutdown

Executor adapters retain their cleanup owners with the application. Closing first invalidates work and unmounts the tree, then starts asynchronous cleanup. The supplied root and its Rust dependencies remain allocated until cleanup acknowledges completion. The two-second diagnostic deadline reports unfinished cleanup; it never pretends a blocked task stopped. An AppContext::closed() observer does not drive cleanup, and dropping it does not stop host wakeups. Native cartridge code stays loaded while it can still be used.

Ordinary Rust destructors must return promptly. Pax does not turn arbitrary blocking Drop implementations into asynchronous cleanup. Task error reporting continues during retained shutdown; its sink must avoid detached UI once the application is Closing.

Asynchronous work

Event handlers start work and return so input and rendering can continue. Pax tracks work against an application, node or operation lifetime and wakes the UI when results are ready. Do not poll a channel from @tick or block the UI loop with Runtime::block_on.

Set up Tokio

Add the adapter under the application's native dependencies:

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
pax-tokio = "0.39.0"
tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }

You construct and configure the exact runtime. The adapter does not initialize another Tokio instance. In a native root initializer:

#![allow(unused)]
fn main() {
fn init_notes(cx: &InitContext) -> AppResult<Notes> {
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()?;
    let executor = pax_tokio::Executor::owned(runtime, cx.lifetime())?;
    Ok(Notes {
        title: Property::new("Loading".into()),
        executor: Some(executor),
    })
}
}

For this variant, add #[pax(rust)] pub executor: Option<pax_tokio::Executor> to Notes. A synchronous handler can then use that explicitly supplied client:

#![allow(unused)]
fn main() {
impl Notes {
    pub fn load_title(&mut self, ctx: &NodeContext, _: Event<ButtonClick>) {
        self.executor.as_ref().unwrap().for_node(ctx).unwrap()
            .spawn_into(&self.title, async {
                tokio::time::sleep(std::time::Duration::from_millis(600)).await;
                "Ready".to_owned()
            }).unwrap();
    }
}
}

Bind a Button's @button_click=self.load_title to try it. Construct Tokio I/O and timers inside the async block, where its executor is active. UI callbacks do not implicitly enter Tokio; free-standing tokio::spawn in such a callback is not the integration pattern. The portable Workbench selects the appropriate executor through a target-specific module.

Choose ownership and result delivery

spawn_into creates a cancellable operation and publishes its return value through a guarded writer. Cancellation or unmount revokes that writer before requesting task abortion. A late result cannot overwrite state written after revocation. A value already committed is not rolled back. Keep the returned TaskControl to cancel it or observe TaskStatus; dropping the control leaves the work owned by its lifetime. A task panic is reported separately from an application's ordinary Result<T, E>. Recoverable panic reporting requires an unwinding build; panic=abort, including the usual browser build, terminates execution instead.

for_node(ctx) uses the exact node in the context. An inline button handler usually receives that button's context. For component lifetime, retain a bound helper or scope from the component's on_mount, for example in a lexical local store. for_application(&app) deliberately selects application lifetime. Neither an Rc dependency handle, NodeContext, nor LocalProperty can be sent into a native task; native task futures must be Send + 'static.

For replaceable requests, retain an operation scope. Calling owner.replace(&mut previous, &result, loading_state) revokes the previous operation before publishing the new loading state and allocating its successor. Pass the resulting scope to executor.for_scope(scope). Explicit cancellation likewise calls scope.cancel() before setting a Cancelled result. The workbench contains a retained stale producer that ignores cancellation and still cannot publish into its replacement.

A task may also capture raw shared Property clones using spawn. Such writes are application data publications: cancelling its scope does not revoke those clones. Use this for intentionally retained shared state, or use a guarded publisher from an operation scope when cancellation must remove write authority. Intermediate property revisions may coalesce. Worker reads see published snapshots, while the UI imports them into its local graph at safe boundaries.

Use spawn_with_completion(future, callback) to manipulate local state after work completes. The callback receives TaskOutcome<T> on the UI thread and may capture local properties. An executor-independent scope.completion(callback) returns a transferable, single-use endpoint for ordinary threads or callback APIs. Dropping an unused endpoint releases its callback on the next UI turn.

For every accepted event to be delivered in order, use scope.channel(capacity, callback). Keep its UiSubscription; dropping it closes the channel. Producers use try_send for explicit Full/Closed errors, or await send for capacity without blocking. Accepted values may still be discarded if the lifetime closes before delivery. Callbacks run after structural settlement and settle their writes before the next callback; queued work cannot call back into a node that was just removed.

Pax bounds asynchronous resource counts. Defaults are 1,024 scopes, tasks, publishers and endpoints each, 4,096 total message slots, and capacity 32 for default_channel. Dispatch rotates ready channels and uses at most 64 callbacks or two milliseconds per turn. A slow callback cannot be preempted; keep callbacks short and bound the size of payloads separately. Hosts schedule work on publication/delivery wakes; applications need no channel polling in @tick.

On macOS, initial layout starts the application independently of the display clock. If CoreVideo cannot start while displays are unavailable, the host retries on display/session wake or application activation. Async delivery still uses its own wake route. Temporary canvas reattachment does not close the application; window close or canvas disposal does.

Runtime ownership and target limits

Executor::owned accepts a multithread runtime and moves its eventual destruction to an owner thread. A started spawn_blocking job cannot be forcibly aborted, so shutdown may remain pending until it finishes. Bound blocking work and use cooperative cancellation. Executor::borrowed(handle, lifetime) leaves the runtime alive; its external owner must continuously drive it and keep it alive through adapter shutdown. A dedicated service thread can drive a current-thread runtime and a LocalSet, keeping its non-Send state on that thread. This does not make UI nodes transferable. The external driver must outlive the adapter's cleanup acknowledgement. A Rust Runtime or Handle can be shared only within the same Rust linkage boundary; do not pass it from the native launcher through the cartridge C ABI.

The adapter's raw handle() is available for advanced integrations. Tasks spawned through it bypass Pax staging, tracking and scope cancellation. Scoped tasks created during initialization or mounting wait until host activation. Discarding preparation never starts them. Pax-only reload preserves the initialized root and its dependencies; actual node unmounts cancel their scopes. Rust changes require rebuilding and restarting.

Browser initializers can construct pax_web_async::Executor::new(cx.lifetime())? under Wasm-only dependencies and use the same bound helper methods. Browser futures need not be Send. They run on the browser thread and use browser timers and fetch, not desktop Tokio drivers. Dropping a future only cancels its Rust polling; the I/O implementation must release or abort external resources. Browser suspension, mobile backgrounding and abrupt process termination do not guarantee cleanup or continued execution. Native I/O remains subject to platform permissions. The fixture README at examples/fixtures/async-workbench/README.md records which targets and build modes have actually been qualified.

For networking in a sandboxed macOS release, explicitly configure the required network entitlements. The same section explains iOS local-network privacy and background-execution limits. Using Tokio does not grant operating-system permissions.

Try Async Workbench

The repository's examples/fixtures/async-workbench is a complete application using these APIs. From the repository root, with a built pax-cli, run one target:

pax-cli run --path examples/fixtures/async-workbench --target macos
pax-cli run --path examples/fixtures/async-workbench --target web
pax-cli run --path examples/fixtures/async-workbench --target ios --ios-device simulator

For the sandboxed native release, use pax-cli build --path examples/fixtures/async-workbench --target macos --release and open the resulting app. The fixture opts into both macOS network entitlements. Its Cargo dependencies select Tokio on native targets and browser futures on web.

Use the fixture to explore ownership and delivery:

  1. Choose Run · 600 ms, then Interact. Input remains usable while the executor's timer runs; the result updates without application @tick polling.
  2. Choose Replace A with B. B wins; the retained producer for A cannot publish after its authority has been revoked.
  3. Start work and Cancel, or Unmount the panel. Unmount discards the panel's local state and revokes pending results. Remount creates fresh panel state and scopes; the application-owned counter, history, and dependencies remain.
  4. Choose Stream 100 events. The bounded channel delivers ordered events; a full buffer is handled explicitly rather than dropping arbitrary messages.
  5. Choose Run local I/O. Native performs a loopback TCP exchange; web fetches its bundled sample. Local callback demonstrates ordinary-thread completion into UI-local state.

Start reading src/application.rs for the initializer and application data, src/work_service.rs for executor setup and platform I/O, and src/request_panel.rs for scoped requests, cancellation, and delivery. The fixture's README records debug/release qualification and remaining target limits; its existence is not a claim that every mobile lifecycle scenario has passed.

API details: application lifetime, managed shutdown, async scopes and delivery, Tokio adapter, and browser adapter.

Read further

Continue with Components and Composition for reusable component interfaces, shared state, and custom event contracts.