docs

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:

C
#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:

🚧 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:

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.

C
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:

Writing Lists

To write a list, call ulDOMDataRecordListBegin(), write each row in display order, and call ulDOMDataRecordListEnd().

C
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.

C
// "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.

C
// "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.

C
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:

Owning the Instance

To transfer ownership of an allocated instance to the binding, provide a destroy_holder callback.

C
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.

C
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:

Posting Actions from Code

You can queue actions programmatically from any thread to run during the next sync.

C
// 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.

C
#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.

C
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: