docs

Handling Actions

Handle button clicks and page events in native code using data bindings.

On this page

Actions let the page ask native code to do something when a button is clicked or a key is pressed. A native handler runs on the thread that owns the data.

Example Data

The examples on this page give the player a potion counter and two actions (one takes an amount)β€” see Describing Types with Schemas:

C++
struct Player {
  int health = 80;
  int potions = 3;

  void UsePotion() {
    if (potions > 0) {
      potions--;
      health = 100;
    }
  }

  static constexpr auto schema = dd::Schema(
      dd::Field("health", &Player::health),
      dd::Field("potions", &Player::potions),
      dd::Action("usePotion"),
      dd::Action<int>("heal"));  // carries an amount
};

Native code binds the player instance:

C++
Player player;

// This instance will be exposed to markup as "player.<field>..."
dd::Binding binding = ctx.Bind("player", player);

Firing an Action

Set ul-on:<event> on an element with the path to the actionβ€” the action fires each time the event occurs:

HTML
<p>Health: {{player.health}}</p>
<button type="button" ul-on:click="player.usePotion">
  Drink ({{player.potions}} left)
</button>

Any DOM event name works (eg, ul-on:dblclick or ul-on:keydown).

Canceling the Default Action

Add .prevent after the event name to cancel the event's default action (such as a link navigating)β€” .prevent is the only modifier:

HTML
<a href="#" ul-on:click.prevent="player.usePotion">Drink</a>

A ul-on:submit action always cancels form submission, with or without .preventβ€” a bound form never reloads the page.

🚧 Buttons Inside Forms

A button inside a <form> submits the form by default, which reloads the page unless the form has a ul-on:submit action. Always set type="button" on buttons.

Handling an Action

Register a handler on the binding with the action's name:

C++
binding.OnAction<"usePotion">([&] { player.UsePotion(); });

When Handlers Run

When a user triggers an action, it passes through three stages:

  1. The user clicks an element, and the action waits in a queue.
  2. The next Sync() runs the handler on the home thread (the thread that owns the data).
  3. The same Sync() sends the changed values to the page.

Call Sync() in native code to process the queue:

C++
ctx.Sync();  // runs UsePotion(), then the page shows "Drink (2 left)"

🚧 Keep Calling Sync

Actions from the page reach handlers only inside Sync(). Keep calling it while the page is visible, even when the game is paused.

Calling a Member Function

Pass a member function of the bound type to run it on the bound instance:

C++
binding.OnAction<"usePotion">(&Player::UsePotion);  // runs player.UsePotion()

To call a member function on another object, pass the object before the member function.

Posting Actions from Native Code

Post an action from native code on any thread to place it in the same queue as page actions, running the handler at the next Sync():

C++
// From any thread (eg the input thread's potion hotkey):
binding.PostAction<"usePotion">();

PostAction() is safe to call from any thread, letting other threads hand work to the thread that owns the data. The call returns false if the action wasn't queued, which happens if the action queue is full or the Binding no longer works (such as when its name is rebound or its Context is destroyed)β€” see Binding Threads and Lifetime.

Sending Data with an Action

To pass data with an action, declare the payload type in the schema and accept it in the handler:

C++
binding.OnAction<"heal">([&](int amount) {
  player.health = std::min(player.health + amount, 100);
});

// A healing zone on another thread sends an amount:
binding.PostAction<"heal">(25);

Only native code sends data with an action (events from the page have no payload). The payload can be a scalar or a struct.

Without a Binding

If you don't have a Binding at hand (such as inside a DOM listener), call Context::PostAction() with the binding and action names joined by a dot:

C++
ctx.PostAction("player.heal", 25);

Collapsing Repeated Actions

For actions that fire frequently (such as scroll positions or zoom levels), add dd::OnlyLatest to deliver only the newest data at each Sync():

C++
struct Map {
  double zoom = 1.0;

  static constexpr auto schema = dd::Schema(
      dd::Field("zoom", &Map::zoom),
      dd::Action<double>("setZoom", dd::OnlyLatest));  // newest value only
};

This collapses repeated actions from both native code and the page into a single delivery per Sync().

πŸ“˜ Requests Can Arrive Late

A handler can run after the state that prompted the action has changed (eg, if an item was already dropped). Treat each action as a request that native code can decline.