Command List Filtering with Virtual List and fuse.js
Background
I am using shadcn-svelte’s Command component in my project Kunkun.
It is using Bits UI’s Command component under the hood.
It’s used to render Kunkun’s command list and template UI extension’s list view.
Problem
It may need to render hundreds of commands or list items, and I got performance problem.
Initial load is so slow when there are thousands of list items to render.
Every time I clear search term there is also a huge delay because it needs to render the entire list.
Bits UI’s Command component’s built-in filtering method is also too limited. Its value is a string, and default filtering is on the value. There is also custom filtering option, I can provide a filter function, but still it’s hard to implement a advanced filtering on multiple fields of list item object. For example, I need to filter on title
, subtitle
, and keywords
field. It is still possible. I have to construct a value to object map Record<String, Item>
. Then in the filter function, obtain the original object with value and run filter score function on it.
function customFilter(
commandValue: string,
search: string,
commandKeywords?: string[]
): number {
const obj = dict[commandValue]
const score = ... // run scoring algo
return score
}
This becomes more complicated, and why don’t I use an existing solution like fuse.js
?
Solution
Use fuse.js for filtering (support multi-fields)
Use TanStack Virtual to render the list
- Even when there are thousands of list items, only ~30 will be rendered on the list
This solution should be very simple, just follow TanStack Virtual’s example code in docs.
One problem I had was TanStack virtual assumes there is a single array of items to render, but I need to support sections and items. Sections contains items. TanStack Virtual doesn’t provide an example to deal with this kind of nested structure.
My solution is to render sections and items in the same list <div/>
. Use a separate virtualizer for each section. When items in each section are listed one by one via #each
, they are listed by the virtualizer, so virtualizer can decide whether to really render them based on whether they are really in viewport (this is controlled by scrollMargin
.
The position of each item are controlled with translateY
style property, and calculated based on scrollMargin
. For example, section 1’s scrollMargin
is 0, and if the height of section 1 is 100px, then the scrollMargin for section 2 becomes 100.
Code
// utils.ts this file contains types and functions for genering dummy data
import { faker } from "@faker-js/faker"
export function generateId() {
return Math.random().toString(36).substring(2, 15)
}
export type Item = {
id: string
name: string
description: string
}
export type Section = {
name: string
items: Item[]
sectionRef: HTMLDivElement | null
sectionHeight: number
}
export function getItems(n: number = 10): Item[] {
return Array.from({ length: n }, () => ({
id: generateId(),
name: faker.person.fullName(),
description: faker.lorem.sentence()
}))
}
export function getSections(n: number = 10): Section[] {
return Array.from({ length: n }, () => ({
name: faker.lorem.word(),
items: getItems(3),
sectionRef: null,
sectionHeight: 0
}))
}
shouldFilter
is set to false in Command.Root
.
All the filtering are done with fuse.js and svelte runes. When searchTerm
or list items change, fuse.search
is run to update resultingItems
.
<!-- +page.svelte -->
<script lang="ts">
import { getSections, getItems } from "./utils.js"
import * as Command from "$lib/components/ui/command/index.ts"
import { createVirtualizer, type VirtualItem } from "@tanstack/svelte-virtual"
import VirtualGroup from "./VirtualGroup.svelte"
import Fuse from "fuse.js"
import { setContext } from "svelte"
const itemHeight = 30
setContext("itemHeight", itemHeight)
const sections = $state(getSections(1))
const items = getItems(1000)
let searchTerm = $state("")
let virtualListEl: HTMLDivElement | null = $state(null)
const fuse = new Fuse(items, {
includeScore: true,
threshold: 0.2,
keys: ["name"]
})
let resultingItems = $derived(
// when search term changes, update the resulting items
searchTerm.length > 0 ? fuse.search(searchTerm).map((item) => item.item) : items
)
// section total height is auto derived from section refs
let sectionTotalHeight = $derived(sections.reduce((acc, s) => acc + (s.sectionHeight ?? 0), 0))
// this should be a list of numbers, the first item is 0, the second item equal to first sectionRef.clientHeight, and so on
let sectionsCummulativeHeight = $derived(
sections.map((s, i) => sections.slice(0, i).reduce((acc, s) => acc + (s.sectionHeight ?? 0), 0))
)
let virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: items.length,
getScrollElement: () => virtualListEl,
estimateSize: () => itemHeight,
overscan: 5
})
let virtualItems: VirtualItem[] = $state([])
let itemsTotalSize = $state(0)
$effect(() => {
void resultingItems
$virtualizer.setOptions({ count: resultingItems.length, scrollMargin: sectionTotalHeight })
virtualItems = $virtualizer.getVirtualItems()
itemsTotalSize = $virtualizer.getTotalSize()
})
</script>
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." bind:value={searchTerm} />
<Command.List bind:ref={virtualListEl}>
<div style="position: relative; height: {itemsTotalSize + sectionTotalHeight}px; width: 100%;">
{#each sections as section, i}
<VirtualGroup
heading={section.name}
items={section.items}
parentRef={virtualListEl}
bind:sectionRef={section.sectionRef}
scrollMargin={sectionsCummulativeHeight[i]}
bind:sectionHeight={section.sectionHeight}
{searchTerm}
/>
{/each}
{#each virtualItems as row (row.index)}
<Command.Item
style="position: absolute; top: 0; left: 0; width: 100%; height: {row.size}px; transform: translateY({row.start}px);"
>
<span>{row.index}: {resultingItems[row.index]?.name}</span>
</Command.Item>
{/each}
</div>
</Command.List>
<footer class="">hello</footer>
</Command.Root>
Each section has a sectionRef
and sectionHeight
field, and are bind to VirtualGroup
, so when their height changes, sectionsCummulativeHeight
and sectionTotalHeight
are also updated with $derived
.
Each value in sectionsCummulativeHeight
array is the scrollMargin
for each section (basically means how much space does your previous sections take).
Within each section/VirutalGroup
, filtering is done again with fuse.js on items in the section.
<!-- VirtualGroup.svelte -->
<script lang="ts">
import * as Command from "$lib/components/ui/command/index.ts"
import type { Item } from "./utils.ts"
import { getContext, onMount } from "svelte"
import { createVirtualizer, type VirtualItem } from "@tanstack/svelte-virtual"
import Fuse from "fuse.js"
let {
heading,
items,
parentRef,
searchTerm,
sectionHeight = $bindable(0),
sectionRef = $bindable(null),
scrollMargin = $bindable(0)
}: {
heading: string
items: Item[]
sectionHeight: number
searchTerm: string
parentRef: HTMLDivElement | null
sectionRef: HTMLDivElement | null
scrollMargin: number
} = $props()
const fuse = new Fuse(items, {
includeScore: true,
threshold: 0.2,
keys: ["name"]
})
const itemHeight = getContext<number>("itemHeight") ?? 30
let virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: items.length,
getScrollElement: () => parentRef,
estimateSize: () => itemHeight,
overscan: 5
})
let virtualItems: VirtualItem[] = $state([])
let itemsTotalSize = $state(0)
let resultingItems = $derived(
// when search term changes, update the resulting items
searchTerm.length > 0 ? fuse.search(searchTerm).map((item) => item.item) : items
)
$effect(() => {
// when props.items update, update the fuse collection
fuse.setCollection(items)
})
$effect(() => {
// when resultingItems changes, update virtualizer count and scrollMargin
$virtualizer.setOptions({ count: resultingItems.length, scrollMargin })
virtualItems = $virtualizer.getVirtualItems()
itemsTotalSize = $virtualizer.getTotalSize()
})
$effect(() => {
sectionHeight = itemsTotalSize + itemHeight
})
</script>
<Command.Group
heading={`${heading} (${items.length})`}
bind:ref={sectionRef}
class="relative"
style="height: {sectionHeight}px;"
>
{#each virtualItems as row (row.index)}
<Command.Item
style="position: absolute; top: 0; left: 0; width: 100%; height: {row.size}px; transform: translateY({row.start -
scrollMargin + itemHeight}px);"
>
<span>{row.index}: {resultingItems[row.index]?.name}</span>
</Command.Item>
{/each}
</Command.Group>