List Patterns Render only visible rows for 10K+ row performance.
For very large datasets (10,000+ rows), virtual scrolling renders only visible rows instead of all items. This dramatically improves both memory usage and update performance.
This is an advanced pattern. Before implementing virtual scrolling, ensure you understand:
| Metric | Standard Rendering | Virtual Scrolling | Improvement |
|---|---|---|---|
| Memory (10K rows) | ~29 MB | ~5 MB | 5.5x less |
| Create 10K rows | ~250ms | ~24ms | 10x faster |
| Update every 10th | ~86ms | ~9ms | 9.5x faster |
| DOM elements | 10,000 | ~17 | 588x fewer |
| Dataset Size | Recommended Approach |
|---|---|
| < 1,000 rows | Standard rendering with keys |
| 1,000 - 10,000 rows | Standard rendering (Eleva handles efficiently) |
| 10,000+ rows | Virtual scrolling |
A user directory that loads employees from an API with search and selection.
// Virtual scrolling configuration
const ROW_HEIGHT = 45;
const VIEWPORT_HEIGHT = 400;
const BUFFER = 3;
const VISIBLE_COUNT = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT) + (BUFFER * 2);
app.component("UserDirectory", {
setup({ signal, emitter }) {
const users = signal([]);
const selectedUser = signal(null);
const searchQuery = signal("");
const scrollTop = signal(0);
const loading = signal(false);
const error = signal(null);
// Fetch users from API
async function fetchUsers() {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/users");
users.value = await response.json();
} catch (err) {
error.value = "Failed to load users";
} finally {
loading.value = false;
}
}
// Filter users by search query
function getFilteredUsers() {
const query = searchQuery.value.toLowerCase();
if (!query) return users.value;
return users.value.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.department.toLowerCase().includes(query)
);
}
// Handle scroll for virtual rendering
function handleScroll(e) {
scrollTop.value = e.target.scrollTop;
}
// Select user to view details
function selectUser(id) {
selectedUser.value = users.value.find(u => u.id === id);
emitter.emit("user:selected", selectedUser.value);
}
// Delete user
async function deleteUser(id) {
if (!confirm("Delete this user?")) return;
try {
await fetch(`/api/users/${id}`, { method: "DELETE" });
users.value = users.value.filter(u => u.id !== id);
if (selectedUser.value?.id === id) {
selectedUser.value = null;
}
} catch (err) {
alert("Failed to delete user");
}
}
return {
users, selectedUser, searchQuery, scrollTop, loading, error,
getFilteredUsers, handleScroll, selectUser, deleteUser,
onMount: fetchUsers
};
},
template: (ctx) => {
const filteredUsers = ctx.getFilteredUsers();
const scroll = ctx.scrollTop.value;
// Calculate visible window
const startIndex = Math.max(0, Math.floor(scroll / ROW_HEIGHT) - BUFFER);
const endIndex = Math.min(filteredUsers.length, startIndex + VISIBLE_COUNT);
const visibleUsers = filteredUsers.slice(startIndex, endIndex);
const offsetY = startIndex * ROW_HEIGHT;
const totalHeight = filteredUsers.length * ROW_HEIGHT;
if (ctx.loading.value) {
return `<div class="loading">Loading users...</div>`;
}
if (ctx.error.value) {
return `<div class="error">${ctx.error.value}</div>`;
}
return `
<div class="user-directory">
<div class="toolbar">
<input
type="search"
placeholder="Search users..."
value="${ctx.searchQuery.value}"
@input="(e) => searchQuery.value = e.target.value"
/>
<span class="count">${filteredUsers.length} users</span>
</div>
<div class="table-viewport" style="height: ${VIEWPORT_HEIGHT}px;" @scroll="handleScroll">
<div style="height: ${totalHeight}px; position: relative;">
<table style="position: absolute; top: ${offsetY}px; width: 100%;">
<tbody>
${visibleUsers.map(user => `
<tr key="${user.id}"
class="${ctx.selectedUser.value?.id === user.id ? 'selected' : ''}"
style="height: ${ROW_HEIGHT}px;">
<td class="col-name">
<a @click="() => selectUser(${user.id})">${user.name}</a>
</td>
<td class="col-email">${user.email}</td>
<td class="col-dept">${user.department}</td>
<td class="col-actions">
<button @click="() => deleteUser(${user.id})">Delete</button>
</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
</div>
${ctx.selectedUser.value ? `
<div class="user-details">
<h3>${ctx.selectedUser.value.name}</h3>
<p>${ctx.selectedUser.value.email}</p>
<p>${ctx.selectedUser.value.department}</p>
</div>
` : ""}
</div>
`;
},
style: `
.user-directory { max-width: 800px; }
.toolbar { display: flex; gap: 16px; align-items: center; margin-bottom: 16px; }
.toolbar input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
.count { color: #666; }
.table-viewport { overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; }
tr { border-bottom: 1px solid #eee; }
tr.selected { background: #e3f2fd; }
td { padding: 12px; }
.col-name a { color: #1976d2; cursor: pointer; }
.col-name a:hover { text-decoration: underline; }
.col-email { color: #666; }
.col-dept { color: #888; }
.col-actions button { padding: 4px 8px; cursor: pointer; }
.user-details { margin-top: 16px; padding: 16px; background: #f5f5f5; border-radius: 4px; }
.loading, .error { padding: 20px; text-align: center; }
.error { color: #d32f2f; }
`
});
Only render rows visible in the viewport, plus a small buffer for smooth scrolling:
const startIndex = Math.max(0, Math.floor(scroll / ROW_HEIGHT) - BUFFER);
const endIndex = Math.min(filteredUsers.length, startIndex + VISIBLE_COUNT);
const visibleUsers = filteredUsers.slice(startIndex, endIndex);
The outer container has fixed height with overflow scroll. The inner spacer maintains correct scrollbar size:
<div class="table-viewport" style="height: ${VIEWPORT_HEIGHT}px;" @scroll="handleScroll">
<div style="height: ${totalHeight}px; position: relative;">
<table style="position: absolute; top: ${offsetY}px;">
<!-- Only visible rows rendered here -->
</table>
</div>
</div>
Use arrow functions when passing arguments to handlers:
// Correct - arrow function defers execution
<a @click="() => selectUser(${user.id})">${user.name}</a>
<button @click="() => deleteUser(${user.id})">Delete</button>
// Wrong - executes immediately during render
<a @click="selectUser(${user.id})">...</a>
Apply filters before calculating the visible window:
function getFilteredUsers() {
const query = searchQuery.value.toLowerCase();
if (!query) return users.value;
return users.value.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
// In template - filter first, then slice
const filteredUsers = ctx.getFilteredUsers();
const visibleUsers = filteredUsers.slice(startIndex, endIndex);
| Syntax | What Happens | Result |
|---|---|---|
@click="handleClick" |
References function directly | Works (no params) |
@click="deleteUser(5)" |
Executes immediately during render | Broken |
@click="() => deleteUser(5)" |
Creates function, calls on click | Works |
The arrow function wraps the call, deferring execution until the actual click event occurs. Without it, the function runs during template rendering—not when clicked.
A drop-in virtual list component that accepts data via props.
// VirtualList - Reusable component, drop into any project
app.component("VirtualList", {
setup({ signal, props, emitter }) {
const scrollTop = signal(0);
// Configuration via props
const rowHeight = props.rowHeight || 40;
const height = props.height || 300;
const buffer = 3;
const visibleCount = Math.ceil(height / rowHeight) + (buffer * 2);
function handleScroll(e) {
scrollTop.value = e.target.scrollTop;
}
function handleItemClick(item) {
emitter.emit("item:click", item);
}
return {
scrollTop, rowHeight, height, buffer, visibleCount,
handleScroll, handleItemClick
};
},
template: (ctx) => {
const items = ctx.props.items || [];
const labelKey = ctx.props.labelKey || "label";
const scroll = ctx.scrollTop.value;
const startIndex = Math.max(0, Math.floor(scroll / ctx.rowHeight) - ctx.buffer);
const endIndex = Math.min(items.length, startIndex + ctx.visibleCount);
const visibleItems = items.slice(startIndex, endIndex);
const offsetY = startIndex * ctx.rowHeight;
const totalHeight = items.length * ctx.rowHeight;
return `
<div class="virtual-list" style="height: ${ctx.height}px;" @scroll="handleScroll">
<div class="virtual-list-spacer" style="height: ${totalHeight}px;">
<div class="virtual-list-content" style="top: ${offsetY}px;">
${visibleItems.map(item => `
<div key="${item.id}"
class="virtual-list-item"
style="height: ${ctx.rowHeight}px;"
@click="() => handleItemClick(${JSON.stringify(item).replace(/"/g, '"')})">
${item[labelKey]}
</div>
`).join("")}
</div>
</div>
</div>
`;
},
style: `
.virtual-list { overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; }
.virtual-list-spacer { position: relative; }
.virtual-list-content { position: absolute; left: 0; right: 0; }
.virtual-list-item { display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid #eee; cursor: pointer; }
.virtual-list-item:hover { background: #f5f5f5; }
`
});
app.component("MyPage", {
setup({ signal, emitter }) {
const users = signal([
{ id: 1, name: "Alice Johnson" },
{ id: 2, name: "Bob Smith" },
// ... thousands of items
]);
emitter.on("item:click", (user) => {
console.log("Selected:", user);
});
return { users };
},
template: (ctx) => `
<h2>Users (${ctx.users.value.length})</h2>
<virtual-list
:items="users"
:labelKey="'name'"
:height="400"
:rowHeight="50"
></virtual-list>
`,
children: {
"virtual-list": "VirtualList"
}
});
| Prop | Type | Default | Description |
|---|---|---|---|
items |
Array | [] |
Data array (each item needs id) |
labelKey |
String | "label" |
Property to display |
height |
Number | 300 |
Viewport height in pixels |
rowHeight |
Number | 40 |
Row height in pixels |
| Event | Payload | Description |
|---|---|---|
item:click |
Item object | Emitted when row is clicked |
| Concept | Implementation |
|---|---|
| Fixed row height | Required for calculating positions |
| Scroll tracking | @scroll handler updates scrollTop signal |
| Window calculation | startIndex, endIndex based on scroll position |
| Spacer element | Inner div with totalHeight for scrollbar |
| Absolute positioning | Table/content positioned at offsetY |
| Buffer rows | Extra rows above/below for smooth scrolling |
| ← Patterns | Back to Patterns Index |