About Data Bindings
Bind native data to HTML markup and respond to page events in application code.
On this page
Data bindings connect native data to page HTML and CSS.
Your markup declares where each value appears and which buttons and input trigger which actions, and the library keeps the page in sync automatically.
This offers a number of benefits over syncing the page with your application via JavaScript:
| Benefit | Details |
|---|---|
| Faster performance | All logic is performed in native code, and layout / style re-calc is batched with data modifications. |
| Lower memory | Native data is bound directly to the DOM without any intermediate JavaScript values. |
| Easier to write | Keep styles and layout in HTML and CSS and keep the data model and logic in native code (hello MVC!). Use mock data to easily design pages in any browser. |
| Safer to use | Validate data bindings in HTML using automatic schema export from native code. |
Going From DOM API to Data Bindings
Ultralight already offers API to manipulate JS and DOM directly from native code so why would you use data bindings?
The short answer is that the former tightly couples application logic to page presentation, which is bad for maintainability (what if you decide to change your page's layout or element ID names?).
Let's compare the two:
With the DOM API
With the DOM API, native code updates each element itself every time data changes:
document.getElementById("name").textContent = player.name();
document.getElementById("armor").textContent =
std::to_string(player.stats().armor);
With Data Bindings
With data bindings, your HTML and CSS markup declare where each value goes:
<h1 ul-text="player.name"></h1>
<p>Armor: {{player.stats.armor}}</p>
Then in native code, you notify the engine whenever you want to broadcast data changes to the page (usually once per frame).
ctx.Sync();
The markup looks familiar if you use Vue or Alpine.js in JavaScript, but the data lives in native code rather than in page scripts.
You can still use the DOM API for one-off work like focusing a field or measuring an element— both approaches mix on one page.
Getting Started
1. Describe Your Types
A schema lists the fields that native code exposes to the page and the actions the page can fire in native code.
Include <Ultralight/dom/data/Context.h> to access the data binding types (they aren't included by <Ultralight/DOM.h>).
Plain structs bind automatically by member name without a schema, while existing classes declare a dd::TypeTraits specialization:
#include <Ultralight/dom/data/Context.h>
namespace dd = ultralight::dom::data;
// A plain struct binds as-is: stats.health, stats.armor.
struct Stats {
int health = 100;
int armor = 0;
};
// An existing class from your game.
class Player {
public:
const std::string& name() const;
const Stats& stats() const;
void TakeDamage(int amount);
void UsePotion();
};
template <> struct dd::TypeTraits<Player> {
static constexpr auto schema = dd::Schema(
dd::Field("name", &Player::name), // reads player.name()
dd::Field("stats", &Player::stats), // a nested object
dd::Action("usePotion")); // an action the page can fire
};
2. Bind and Attach
A dd::Context connects native objects to the pages loaded by one or more View instances.
Call Bind() to register an instance under a name the page can use, and chain handlers to receive actions from the page into native code:
dd::Context ctx = dd::Context::Create();
if (!ctx.AttachTo(view.get()))
Log("couldn't attach the data context");
Player& player = game.LocalPlayer(); // your existing object
dd::Binding binding = ctx.Bind("player", player) // the page calls it "player"
.OnAction<"usePotion">(&Player::UsePotion); // the button below calls this
view->LoadURL("file:///character.html");
3. Use It in the Page
In HTML, you reference data with ul-* attributes and {{path}} text:
<div class="character-panel">
<h1 ul-text="player.name"></h1>
<p>Armor: {{player.stats.armor}}</p>
<progress max="100" ul-attr:value="player.stats.health"></progress>
<button ul-on:click="player.usePotion">Drink Potion</button>
</div>
4. Change Your Data and Sync
Call Sync() once per frame to deliver page events to native handlers and push data to the page:
player.TakeDamage(10);
ctx.Sync(); // runs UsePotion() if clicked, then updates the page
Each call to Sync() performs three operations in order:
- Delivers events from the page to native handlers (changes first, then actions).
- Reads every bound object through its schema.
- Broadcasts changed values from native code to the page in a single batch.
How Data and Input Flow
Data flows from native code to the page, while requests such as actions and change requests flow from the page to native code.
| Direction | What Travels | Markup | Native Code |
|---|---|---|---|
| Native code to the page | Data (fields, lists, and CSS variables) | {{path}}, ul-text, ul-attr, ul-class, ul-style, ul-var, ul-show, ul-if, ul-for |
Modify native objects, call Sync() |
| Page to native code | Actions (clicks or other events) | ul-on:click |
OnAction() |
| Page to native code | Value changes in form fields | ul-value |
OnChange() |
Two-Way Binding with ul-value
The only two-way binding is ul-value (eg, a slider bound to settings.volume). It works as a round trip rather than a shared variable:
- The user moves the slider on the page. The page reflects the new position immediately and sends a change request from the page to native code.
- The
OnChange()handler validates the data (potentially modifying / blocking it) and updates the corresponding value in native data. The nextSync()sends the confirmed value back to the page.
Designing Pages with Mock Data
You can build and style data-bound pages in any browser without running the application.
The generator creates mockup files from the application's schema so the page displays real model data. It can also check markup against the schema in a build step— typos in binding paths get caught before the page runs.
- Save the schema and current values of every bound model from native code (export this in development builds only):
ctx.DumpSchema("ui/schema.json"); // written at the end of the next Sync()
- Run
gen-mock-data.pyfrom the SDK root folder (the directory holdingtools/, not from insidetools/scripts):
python tools/scripts/gen-mock-data.py ui/schema.json -o ui/
This creates the mockup scripts beside the pages. The mock-data.js file is written only once, so future runs preserve any custom mock data added to it.
- Add the mock script tag to the page:
<script src="ul-mock.js"></script>
Open the HTML file in any browser to inspect the page with the saved values. The script loads nothing inside Ultralight, so the tag can stay in the shipped page.
To define custom mock data, handle actions, test scenarios, or run the check build step, see Mocking Bound Pages.
For C projects, see Data Bindings in C.