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

> Learn to build virtualizing lists with 100k+ rows in the Native SDK. This guide explains efficient dataset rendering using ui.virtualWindow and ui.virtualList for optimal performance.

- Repository: [Vercel Labs/native](https://github.com/vercel-labs/native)
- Tags: how-to-guide
- Published: 2026-07-18

---

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

```zig
// 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:

```zig
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:

```zig
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:

```zig
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:

```zig
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:

```zig
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.