Data Bindings in C
Expose application data to page markup and handle input requests in C.
On this page
You can expose your application's data to page markup and receive input requests back in C using declarative data bindings. The C API uses the same ul-* attributes, sync order, and binding rules as C++— read About Data Bindings first for the core concepts.
This guide covers what differs when working in C. For general conventions covering memory ownership, callbacks, and threading, see C API Conventions.
📘 Preview API
Declarative data binding in C is a preview API and may change in future releases. It requires
<Ultralight/CAPI/CAPI_DOMData.h>, which also includes the DOM document and element headers.
Differences From C++
The C API provides the same declarative data binding capabilities as C++, using runtime builders, slot indices, and callbacks:
| C++ | C | What Changes |
|---|---|---|
dd::Schema, dd::TypeTraits, reflection |
ulCreateDOMDataTypeBuilder() |
Built at runtime, logging warnings on failure instead of failing at compile time |
| Field accessors | Walk callback (ULDOMDataSyncWalkCallback) |
Writes each field manually on each sync |
float member |
Double field with kULDOMDataEntryFlags_Float |
Formats values with float precision on the page |
| Enum member | ulDOMDataTypeBuilderAddEnumField() |
Stores allowed names in the schema for export and mock data |
dd::Validate |
None | Checked manually inside the change callback |
| Reference, value, or smart pointer | holder_state with acquire, release, and destroy callbacks |
Manages instance lifetime and access through explicit callbacks |
OnChange<"field">(), OnAction<"name">() |
One change callback and one action callback per binding | Dispatches requests by switching on the entry slot |
| Row action handlers | Shared action callback | Identifies the list and row through source |
PostTask() template |
ulDOMDataContextPostTask(), ulDOMDataContextPostTaskOnce() |
Uses separate functions for per-view documents and single completion tasks |
Binding destructor |
ulDestroyDOMDataBinding() |
Unbinds when the last handle is destroyed |
Describing a Type
To describe a type in C, create a builder, add each entry in order, build the type, and destroy the builder:
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMData.h>
#include <string.h>
typedef struct {
long long id;
const char* name;
long long count;
} Item;
typedef struct {
long long health;
double volume;
Item items[8];
size_t item_count;
} Player;
// Each entry's slot is its position in the order it's added below.
enum { kAmount_Value };
enum { kItem_Name, kItem_Count, kItem_Drop };
enum { kPlayer_Health, kPlayer_Volume, kPlayer_UsePotion, kPlayer_Heal,
kPlayer_Items };
static ULDOMDataType amount_type;
static ULDOMDataType item_type;
static ULDOMDataType player_type;
bool DescribeTypes(void) {
bool ok = true;
// A payload holding one value names its field "value".
ULDOMDataTypeBuilder b = ulCreateDOMDataTypeBuilder("Amount");
ok &= ulDOMDataTypeBuilderAddField(b, "value", kULDOMDataValueKind_Int64, 0);
amount_type = ulDOMDataTypeBuilderBuild(b);
ulDestroyDOMDataTypeBuilder(b);
b = ulCreateDOMDataTypeBuilder("Item");
ok &= ulDOMDataTypeBuilderAddField(b, "name", kULDOMDataValueKind_String, 0);
ok &= ulDOMDataTypeBuilderAddField(b, "count", kULDOMDataValueKind_Int64, 0);
ok &= ulDOMDataTypeBuilderAddAction(b, "drop", NULL, false);
item_type = ulDOMDataTypeBuilderBuild(b);
ulDestroyDOMDataTypeBuilder(b);
b = ulCreateDOMDataTypeBuilder("Player");
ok &= ulDOMDataTypeBuilderAddField(b, "health", kULDOMDataValueKind_Int64,
0);
ok &= ulDOMDataTypeBuilderAddField(b, "volume", kULDOMDataValueKind_Double,
kULDOMDataEntryFlags_Editable);
ok &= ulDOMDataTypeBuilderAddAction(b, "usePotion", NULL, false);
ok &= ulDOMDataTypeBuilderAddAction(b, "heal", amount_type, false);
ok &= ulDOMDataTypeBuilderAddList(b, "items", item_type,
kULDOMDataValueKind_Int64);
player_type = ulDOMDataTypeBuilderBuild(b);
ulDestroyDOMDataTypeBuilder(b);
return ok; // false: a failed Add shifted the slots after it
}
Several details matter when you define a type:
- Define an enum for each type to name its slots. Record writes, change callbacks, and posted actions all use numeric slots.
- Build row, child, and payload types first. Functions like
ulDOMDataTypeBuilderAddList()andulDOMDataTypeBuilderAddObject()require a built type and rejectNULL. - A built
ULDOMDataTypelasts for the program. Build each type once at startup and never destroy it— callingulDOMDataTypeBuilderBuild()again on the same builder returns the existing type.
🚧 Check Return Values
A failed
ulDOMDataTypeBuilderAdd*()call logs a warning and assigns no slot, shifting every subsequent entry's index down by one. Writing to a shifted slot of the same kind modifies the wrong field, while writing to a mismatched kind does nothing. Always check the return value of each addition to ensure your slot enum stays aligned.
Float and Enum Fields
Single-precision floats and enums require dedicated field configurations in C:
static const char* const kRanks[] = {"Bronze", "Silver", "Gold"};
// A C float: a double field shown with float precision.
ok &= ulDOMDataTypeBuilderAddField(b, "speed", kULDOMDataValueKind_Double,
kULDOMDataEntryFlags_Float);
// An enum: a string field that lists its names for the schema.
ok &= ulDOMDataTypeBuilderAddEnumField(b, "rank", kRanks, 3, 0);
Float fields store their values as doubles internally. Without kULDOMDataEntryFlags_Float, the page displays every digit of the double precision value.
Enum fields store allowed names as metadata for schema exports and mock data. The library doesn't check written values or page edits against this list, so validate incoming string edits inside your change callback.
Writing Values
The walk callback writes the current values of an instance to the renderer during each sync.
static void WalkPlayer(ULDOMDataRecord record, const void* instance) {
const Player* p = (const Player*)instance;
ulDOMDataRecordSetInt64(record, kPlayer_Health, p->health);
ulDOMDataRecordSetDouble(record, kPlayer_Volume, p->volume);
WriteItems(record, p);
}
A walk callback works within a few simple limits:
- The record handle is valid only during the walk. Never store it or attempt to destroy it.
- Writing an unchanged value sends nothing to the page. Any field you omit keeps its current value on the page.
- Keep the walk callback a pure read. The callback also runs during schema exports like
ulDOMDataContextGetSchema()andulDOMDataContextDumpSchema(). Keep it free of side effects.
Writing Lists
To write a list, call ulDOMDataRecordListBegin(), write each row in display order, and call ulDOMDataRecordListEnd().
static void WriteItems(ULDOMDataRecord record, const Player* p) {
ulDOMDataRecordListBegin(record, kPlayer_Items, p->item_count);
for (size_t i = 0; i < p->item_count; i++) {
const Item* item = &p->items[i];
ULDOMDataRecord row =
ulDOMDataRecordListRowKeyInt64(record, kPlayer_Items, item->id);
ulDOMDataRecordSetString(row, kItem_Name, item->name, strlen(item->name));
ulDOMDataRecordSetInt64(row, kItem_Count, item->count);
}
ulDOMDataRecordListEnd(record, kPlayer_Items);
}
The order you fetch row records sets their display order on the page.
Any rows you don't write between ulDOMDataRecordListBegin() and ulDOMDataRecordListEnd() are removed from the page— without ulDOMDataRecordListEnd(), existing rows and their order don't change.
For the differences between keyed and positional lists, see Binding Lists.
Optional Objects
A nullable object starts absent on the page until you mark it present.
// "target" was added with ulDOMDataTypeBuilderAddObject(..., true).
ulDOMDataRecordSetPresent(record, kHud_Target, hud->has_target);
if (hud->has_target) {
ULDOMDataRecord target = ulDOMDataRecordGetChild(record, kHud_Target);
ulDOMDataRecordSetString(target, kTarget_Name, hud->target_name,
strlen(hud->target_name));
}
Call ulDOMDataRecordSetPresent() to set whether the object is present, then write its fields through ulDOMDataRecordGetChild().
While an object is absent, ul-if attributes checking it evaluate to false— page bindings that read through it display default values.
Skipping Unchanged Data
You can skip rewriting unchanged collections by tracking a revision counter in an internal field.
// "rev" is an Internal Int64 field. Start board->rev at 1 and bump it
// whenever the rows change.
if (ulDOMDataRecordGetInt64(record, kBoard_Rev) != board->rev) {
ulDOMDataRecordSetInt64(record, kBoard_Rev, board->rev);
WriteRows(record, board);
}
ulDOMDataRecordSetString(record, kBoard_Title, board->title,
strlen(board->title));
Reading a record returns the value last sent to the page, including any writes made earlier in the current walk callback— compare the counter before you write the new value.
Start your counter at 1, since reading a field before the first sync returns 0.
For why and when to use this pattern, see Building Data-Driven UI.
Binding and Syncing
To display data on a page, create a context, bind the instance under a name, attach the context to a View, and sync once per frame.
static Player player = {80, 0.8, {{1, "Potion", 3}, {2, "Sword", 1}}, 2};
static ULDOMDataContext ctx;
static ULDOMDataBinding binding;
// Returns the instance to read on each Sync.
static const void* BorrowPlayer(void* holder_state) {
return holder_state;
}
bool BindPlayer(ULView view) {
ctx = ulCreateDOMDataContext(); // this thread becomes its home thread
binding = ulDOMDataContextBind(ctx, "player", player_type, WalkPlayer,
&player, BorrowPlayer, NULL, NULL);
return ulViewAttachDOMDataContext(view, ctx,
kULDOMDataContextAttachFlags_None, NULL, 0);
}
void Tick(void) {
player.health -= 1;
ulDOMDataContextSync(ctx);
}
Connecting an instance to a view involves a few key steps:
- Pass an instance you keep alive yourself as
holder_state. Theacquirecallback returns it during each sync. PassNULLforreleaseanddestroy_holder. - Attach the context to a View with
ulViewAttachDOMDataContext(). PassingNULLand0uses the default origin rules— see Choosing Which Pages Get the API. - Keep the returned
ULDOMDataBindinghandle while the instance is bound. Destroying its last handle unbinds the instance. Binding the same name again replaces the earlier binding.
Owning the Instance
To transfer ownership of an allocated instance to the binding, provide a destroy_holder callback.
Player* copy = malloc(sizeof *copy);
*copy = player;
// The binding owns the copy and frees it when it's unbound.
ULDOMDataBinding saved = ulDOMDataContextBind(
ctx, "saved", player_type, WalkPlayer, copy, BorrowPlayer, NULL, free);
The destroy_holder callback runs once when the instance unbinds, or immediately if ulDOMDataContextBind() fails. Never free the holder pointer yourself after a failed bind call.
Handling Edits and Actions
Each binding accepts one change callback and one action callback, routing requests through the entry slot.
static void OnChange(void* user_data, unsigned int slot,
const ULDOMDataLeafValue* value) {
Player* p = (Player*)user_data;
if (slot == kPlayer_Volume && value->kind == kULDOMDataValueKind_Double) {
double v = value->double_value;
p->volume = v < 0 ? 0 : (v > 1 ? 1 : v); // store it to accept the edit
}
}
static void OnAction(void* user_data, unsigned int slot,
const ULDOMDataActionSource* source,
const ULDOMDataPayloadEntry* payload,
size_t payload_count, ULDOMDataTable staging) {
(void)staging;
Player* p = (Player*)user_data;
if (source) {
// A row action: slot indexes the row type (Item).
if (source->list_slot == kPlayer_Items && slot == kItem_Drop)
RemoveItem(p, source->int_key);
} else if (slot == kPlayer_UsePotion) {
p->health = 100;
} else if (slot == kPlayer_Heal && payload_count == 1 &&
payload[0].value.kind == kULDOMDataValueKind_Int64) {
p->health += payload[0].value.int64_value;
}
}
void WatchPlayer(void) {
ulDOMDataBindingSetChangeCallback(binding, OnChange, &player, NULL);
ulDOMDataBindingSetActionCallback(binding, OnAction, &player, NULL);
}
Your callbacks handle incoming edits and actions in a few ways:
- Store the requested value to accept an edit. An edit is an incoming request from a form control. Store the value to accept it, or ignore it to reject it— rejected values snap back after the next sync. Clamp or validate values inside your callback, since C bindings lack schema validators. For form controls, see Binding Form Controls.
- Omitting a callback drops incoming requests. Missing change callbacks cause form controls to revert to their published values. Missing action callbacks drop actions.
- Row actions identify the list and row through
source. When a row action fires,sourceis non-NULL. Thesource->list_slotmember identifies the list, andint_keyorstring_keyidentifies the row. Here,slotindexes the action within the row type. Always use keyed lists with row actions, since positional row actions provide only an index that can target the wrong row if items move. Actions fired from markup never have a payload. For row actions, see Binding Lists.
Posting Actions from Code
You can queue actions programmatically from any thread to run during the next sync.
// Safe from any thread; OnAction runs inside the next Sync.
ulDOMDataContextPostAction(ctx, binding, kPlayer_UsePotion);
ULDOMDataPayloadEntry amount = {0};
amount.name = "value";
amount.value.kind = kULDOMDataValueKind_Int64;
amount.value.int64_value = 25;
ulDOMDataContextPostActionWithPayload(ctx, binding, kPlayer_Heal, &amount, 1);
// With no binding handle at hand (eg, in a DOM event listener):
ulDOMDataContextPostActionByName(ctx, "player", "heal", &amount, 1);
Actions with payloads accept an array of zero-initialized ULDOMDataPayloadEntry structs. A single-value payload takes one entry named "value".
Call ulDOMDataContextPostActionByName() when you do not hold a binding handle, such as inside a DOM event listener. It dispatches root actions by name, dropping unknown targets during the next sync.
Formatting Values
Register a formatter with ulDOMDataContextDefineFormat() to transform values into display text in {{path|name}} markup.
#include <stdio.h>
// Shows 0.8 as "80%" in {{player.volume|percent}}.
static void FormatPercent(void* user_data, const ULDOMDataLeafValue* value,
ULString output) {
(void)user_data;
char text[32];
snprintf(text, sizeof text, "%.0f%%", value->double_value * 100.0);
ulStringAssignCString(output, text);
}
void DefineFormats(void) {
ulDOMDataContextDefineFormat(ctx, "percent", FormatPercent, NULL, NULL);
}
The output string starts empty— populate it with ulStringAssignCString(). For details on using formatters in markup, see Showing Data in Markup.
🚧 Formatter Rules
Formatters run on the Renderer's thread. A formatter must return the same text for the same value, and it must never call data-binding functions.
Running Code After Updates
Call ulDOMDataContextPostTask() to run a callback on the Renderer's thread once pages show your latest data.
static void ScrollToLastItem(void* user_data, ULDOMDocument document) {
(void)user_data;
if (!document)
return; // no View attached
ULDOMElement last =
ulDOMDocumentQuerySelector(document, "#items li:last-child", NULL);
ulDOMElementScrollIntoView(last, false);
ulDestroyDOMElement(last);
}
void OnItemAdded(void) {
ulDOMDataContextPostTask(ctx, ScrollToLastItem, NULL, NULL);
}
The callback runs during a later ulUpdate() once for each attached View, receiving that View's ULDOMDocument handle. The document handle is valid only during the call— use ulCreateDOMDocumentRef() if you need to keep it. If no View is attached, the callback runs once with a NULL document. For working with DOM elements, see DOM Access in C.
To receive a single notification after all attached Views apply the update, call ulDOMDataContextPostTaskOnce(). It runs once with a NULL document after every View updates.
Threading and Lifetime
Contexts and bindings follow specific threading and lifetime rules:
- The thread that creates a context is its home thread. You must bind instances, call
ulDOMDataContextSync(), set callbacks, and define formatters on this thread. - Several operations are safe from any thread. Beyond the thread-safe functions in C API Conventions, you can attach or detach Views, post actions, call
ulDOMDataContextWithOwnerTurn(), and dump schemas withkULDOMDataDumpAt_NextSyncfrom any thread. For dumping schemas, see Mocking Bound Pages. - Use a thread-safe context if your sync thread changes. If the thread calling
ulDOMDataContextSync()changes over time, create the context withulCreateDOMDataContextThreadSafe(). Wrap changes to state that your callbacks read insideulDOMDataContextWithOwnerTurn(). - A binding ends when its name is bound again, the instance is unbound, or its context is destroyed. Its handle remains safe to pass to functions, but its callbacks are dropped. Posting actions through it returns
false, andulDOMDataBindingIsAlive()returnsfalse. - Destroy the last owning context handle on the home thread. Destroy callbacks for its bindings and formatters run on the calling thread. For teardown details, see Binding Threads and Lifetime.