How to Build Virtualized Lists with 100k+ Rows in the Native SDK: A Complete Feed Example

The Native SDK renders massive datasets efficiently by separating data from layout, using ui.virtualWindow() to determine visible ranges and ui.virtualList() to build only the rows currently in view.

The vercel-labs/native repository provides a declarative API for handling scroll collections with hundreds of thousands of rows without performance degradation. By leveraging windowed virtual lists, the runtime asks your application only for the visible range, allowing you to build virtualized lists with 100k+ rows while maintaining 60fps scrolling.

Core Concepts of Windowed Virtual Lists

Native SDK virtual lists work by decoupling your data model from the rendered output. Instead of creating DOM nodes for every item, you declare a virtual window that represents the currently visible slice, and the runtime handles the rest.

Key concepts include:

  • virtualized attribute – Defined in tools/native-sdk/markup_docs.zig (lines 19‑20), this attribute enables list virtualization when set on a <list> element.
  • virtual-item-extent – Also documented in markup_docs.zig (lines 21‑22), this fixed height (or width) value allows the runtime to compute total scroll extent without measuring every item.
  • Stable identity – Each row must have a stable key (or global-key) so the runtime can preserve component state across window updates.

The Virtual List API

The SDK exposes two primary functions for windowed rendering, both demonstrated in src/runtime/ui_app_tests.zig (lines 24‑26 and 38‑15):

  • ui.virtualWindow(options) – Creates a window object containing start_index and itemCount(), defining which slice of your data is currently visible.
  • ui.virtualList(options, window, rows) – Assembles the scrollable list from your pre-built row nodes, returning a <list> node that the runtime can re‑window on scroll.

Additional options seen in src/runtime/ui_app_tests.zig (lines 39‑05) include:

  • overscan – Extra rows rendered before/after the viewport to prevent gaps during fast scrolling.
  • on_reach_end – A callback message fired when scrolling reaches the end of loaded items, perfect for infinite-feed pagination.

Building a Feed-Style Implementation

The feed pattern requires wiring together your model, update logic, and view function. Here is the complete implementation derived from the windowed virtual list fixture in src/runtime/ui_app_tests.zig (lines 3884‑3905).

Step 1: Define the Model and Messages

Create a struct to track loaded items and fetch counts, plus an enum for messages:

// Model holds current loaded count and pagination counter
const FeedModel = struct {
    loaded: usize = 400,          // initially show 400 items
    fetches: u32 = 0,
};

// Messages the list can dispatch
const FeedMsg = union(enum) {
    load_more,
};

Step 2: Configure VirtualListOptions

Define your list configuration with fixed row heights. This implementation uses item_extent = 24 as specified in the test fixtures:

fn feedOptions(model: *const FeedModel) FeedApp.Ui.VirtualListOptions {
    return .{
        .id           = "feed",
        .item_count   = model.loaded,
        .item_extent  = 24,               // fixed height → virtual-item-extent
        .overscan     = 2,                // render 2 rows beyond viewport
        .grow         = 1,
        .on_reach_end = .load_more,      // fetch more when scrolling to end
    };
}

Step 3: Build the View with Windowed Rendering

In your view function, allocate only the rows currently visible in the window:

fn feedView(ui: *FeedApp.Ui, model: *const FeedModel) FeedApp.Ui.Node {
    const opts   = feedOptions(model);
    
    // Create window: runtime tells us start_index and how many items to show
    const window = ui.virtualWindow(opts);
    
    // Allocate exactly window.itemCount() nodes - no more, no less
    const rows = ui.arena.alloc(FeedApp.Ui.Node, window.itemCount()) catch {
        ui.failed = true;
        return ui.column(.{}, .{});
    };
    
    // Build each visible row with stable keys
    for (rows, 0..) |*row, offset| {
        const idx = window.start_index + offset;               // global item index
        var node = ui.listItem(.{}, ui.fmt("Item {d}", .{idx}));
        node.key = .{ .int = @intCast(idx) };                 // stable identity for runtime
        row.* = node;
    }
    
    // Return assembled list; runtime handles re-windowing on scroll
    return ui.virtualList(opts, window, .{rows});
}

Step 4: Handle Pagination in Update

When the user scrolls to the end, the runtime dispatches your on_reach_end message:

fn feedUpdate(model: *FeedModel, msg: FeedMsg) void {
    switch (msg) {
        .load_more => {
            model.fetches += 1;
            const batch = 100;
            const cap   = 600;
            if (model.loaded < cap) model.loaded += batch;
        },
    }
}

Runtime Architecture Details

Under the hood, the Native SDK runtime (src/runtime/ui_app.zig) maintains an array of declared virtual windows and resolves visible ranges automatically.

Key implementation details from lines 1280‑1284 show how the runtime stores context:

ui.virtual_window_context = @ptrCast(&window_source);
ui.virtual_window_source = VirtualWindowResolver.resolve;

The engine calculates scroll positions using canvas.virtualListRange, which relies on your fixed item_extent to compute total scroll length (item_count * item_extent) without layout thrashing. When scrolling reaches the end, the runtime fires reach_end_fired_ids with hysteresis to prevent duplicate triggers.

Complete Working Example

Here is the self‑contained feed application ready for production use:

const ui_app_model = @import("native-sdk").ui_app;

// 1️⃣ Model
const FeedModel = struct {
    loaded: usize = 400,
    fetches: u32 = 0,
};

// 2️⃣ Messages
const FeedMsg = union(enum) {
    load_more,
};

const FeedApp = ui_app_model.UiApp(FeedModel, FeedMsg);

// 3️⃣ Update logic
fn feedUpdate(model: *FeedModel, msg: FeedMsg) void {
    switch (msg) {
        .load_more => {
            model.fetches += 1;
            const batch = 100;
            const cap = 600;
            if (model.loaded < cap) model.loaded += batch;
        },
    }
}

// 4️⃣ Virtual list configuration
fn feedOptions(model: *const FeedModel) FeedApp.Ui.VirtualListOptions {
    return .{
        .id = "feed",
        .item_count = model.loaded,
        .item_extent = 24,
        .overscan = 2,
        .grow = 1,
        .on_reach_end = .load_more,
    };
}

// 5️⃣ View function
fn feedView(ui: *FeedApp.Ui, model: *const FeedModel) FeedApp.Ui.Node {
    const opts = feedOptions(model);
    const window = ui.virtualWindow(opts);
    
    const rows = ui.arena.alloc(FeedApp.Ui.Node, window.itemCount()) catch {
        ui.failed = true;
        return ui.column(.{}, .{});
    };
    
    for (rows, 0..) |*row, offset| {
        const idx = window.start_index + offset;
        var node = ui.listItem(.{}, ui.fmt("Item {d}", .{idx}));
        node.key = .{ .int = @intCast(idx) };
        row.* = node;
    }
    
    return ui.virtualList(opts, window, .{rows});
}

// 6️⃣ App options
fn feedAppOptions() FeedApp.Options {
    return .{
        .name = "ui-app-feed",
        .scene = undefined,        // replace with your scene
        .canvas_label = undefined,
        .update = feedUpdate,
        .view = feedView,
    };
}

Summary

  • Windowed rendering via ui.virtualWindow() and ui.virtualList() ensures only visible rows consume memory and CPU.
  • Fixed item extents (virtual-item-extent or item_extent) allow the runtime to calculate scroll extents without measuring every item, as documented in tools/native-sdk/markup_docs.zig.
  • Stable keys (.int = global_index) on <list-item> nodes preserve identity across window updates.
  • Pagination happens automatically via on_reach_end messages dispatched when scrolling reaches the loaded end.
  • The implementation lives in src/runtime/ui_app.zig with test fixtures demonstrating 100k+ compatible patterns in src/runtime/ui_app_tests.zig.

Frequently Asked Questions

How does the Native SDK calculate scroll extents without measuring every row?

The runtime multiplies item_count by the fixed item_extent value you provide in VirtualListOptions. Because you declare a uniform row height upfront, the engine can compute total scrollable distance instantly without DOM measurement, as seen in the canvas.virtualListRange implementation in src/runtime/ui_app.zig.

Why must I assign a stable key to each list item?

The key property (set via node.key = .{ .int = idx }) gives the runtime a stable identity for each row across window updates. Without this, the engine cannot efficiently diff rows when the viewport scrolls, leading to unnecessary rebuilds and lost component state.

Can I use variable row heights with virtual lists?

While the SDK supports variable extents via extent tables in src/runtime/ui_app.zig, the feed example uses fixed heights for performance. Variable heights require the runtime to maintain additional bookkeeping that can impact scrolling performance with 100k+ rows.

How do I prevent double-firing the load_more message when reaching the end?

The runtime implements hysteresis in reach_end_fired_ids (see src/runtime/ui_app.zig), ensuring on_reach_end triggers only once per scroll-to-end event until the user scrolls away and returns to the bottom.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →