Skip to content

Virtual Multi Select

Virtual Multi Select is not a separate built-in component. It is a recommended composition pattern: VirtualList owns cursor movement, windowed rendering, and the confirm key, while business code stores the multi-select set in an external HashSet<usize>. This keeps long lists, selection state, and row rendering on clear boundaries.

virtual multi select demo

This recording is generated by docs/tapes/virtual-multi-select.tape and runs examples/components/virtual_multi_select.rs. The example waits for 10,000 rows to load, restores the default cursor to row 6, checks multiple rows with Space, and submits the current selection count with Enter.

cargo run --example virtual_multi_select

Press j/k to move, Space to toggle the current row, Enter to submit the selected count, and q to quit.

Prefer MultiSelect for ordinary multi-select. Reach for VirtualList + external selected set only when at least one of these is true:

  • The list is long and needs windowed rendering.
  • Each row needs a custom widget or complex style.
  • The selection set belongs to the business model and must be read across panels, submitted in batches, or persisted.
  • Row data comes from an external data source and the component should not own the whole collection.

This pattern is intentionally not captured as a VirtualMultiSelect component. “Selected” in long lists is often tightly bound to business policy: by id, by filtered results, by page, by permission-disabled items. The framework provides composable low-level abilities instead of freezing those policies into a built-in component.

The example has three key pieces of state:

let mut loaded = hooks.use_state(|| false);
let list_state = hooks.use_state(ListState::default);
let selected = hooks.use_state(HashSet::<usize>::default);

loaded controls whether data is ready; list_state is passed to VirtualList for cursor management; selected is the multi-select set owned by business code. VirtualList does not know which rows are checked. It receives a snapshot during render_item and draws visible rows accordingly.

The multi-select shortcut is registered on the page layer, not inside VirtualList:

hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
    let Event::Key(key) = event else {
        return EventResult::Ignored;
    };
    if key.kind != KeyEventKind::Press {
        return EventResult::Ignored;
    }

    match key.code {
        KeyCode::Char(' ') if loaded.get() => {
            if let Some(index) = list_state.read().selected {
                let mut selected_items = selected.write();
                if !selected_items.insert(index) {
                    selected_items.remove(&index);
                }
            }
            EventResult::Consumed
        }
        KeyCode::Enter if loaded.get() => {
            let count = selected.read().len();
            message.set(format!("Submitted {count} selected row(s)"));
            EventResult::Consumed
        }
        _ => EventResult::Ignored,
    }
});

Space only updates selected; it does not change VirtualList behavior. Enter submits the selected count here. If you want Enter to still trigger VirtualList(on_select), you can move submit logic into on_select, but make sure the same key is not consumed by two handlers.

The render_item closure uses a snapshot of the selected set to decide how to draw each row:

let selected_snapshot = selected.read().clone();

VirtualList<Line<'static>>(
    state: list_state,
    item_count: item_count,
    default_index: Some(5usize),
    render_item: move |context: &ListBuildContext| {
        let checked = if selected_snapshot.contains(&context.index) {
            "[x]"
        } else {
            "[ ]"
        };
        let label = format!("{checked} Row {:05}", context.index + 1);
        let style = if context.is_selected {
            Style::default().black().on_cyan()
        } else if selected_snapshot.contains(&context.index) {
            Style::default().fg(Color::Yellow)
        } else {
            Style::default()
        };

        (Line::styled(label, style), 1u16)
    },
)

Cloning a HashSet<usize> here gives render_item a stable snapshot for the current frame. In a real application with a very large selected set, you can switch business selection to HashSet<ItemId> or maintain a lighter lookup structure outside the component. The component API still needs only the current index and visible row context.

NeedChoose
Small to medium list where selected state only serves the current controlMultiSelect
Long list that needs windowed renderingVirtualList + HashSet
Fully custom row height, style, or widgetVirtualList + render_item
Need to persist selection by business id across pagesExternal business state + VirtualList

Next, see VirtualList for the windowed list API itself. If you do not need virtualization, MultiSelect is simpler.