Dynamic Options Loading

For large datasets or server-side data, use load_options to dynamically load options as the user scrolls and searches. The component handles all state management, debouncing, and pagination automatically. Requested in GitHub discussion #342.

Basic Example

This runnable example filters an in-memory dataset with a simulated network delay. Enable Fail the next request, then search to try the built-in Retry button. Search for no match to see the empty-results state.

svelte<script lang="ts">
  import { MultiSelect } from 'svelte-widgets'
  import type { LoadOptionsParams, LoadOptionsResult } from 'svelte-widgets'

  let fail_next = $state(false)

  // stands in for a database/API
  const all_items: string[] = Array.from({ length: 10000 }, (_, idx) => `Item ${idx + 1}`)

  async function load_options(
    params: LoadOptionsParams,
  ): Promise<LoadOptionsResult<string>> {
    const { search, offset, limit, signal } = params
    // simulated network delay
    await new Promise((resolve) => setTimeout(resolve, 300))
    signal?.throwIfAborted()

    if (fail_next) {
      fail_next = false
      throw new Error(`Simulated network failure`)
    }
    const filtered = search
      ? all_items.filter((item) => item.toLowerCase().includes(search.toLowerCase()))
      : all_items

    const options = filtered.slice(offset, offset + limit)
    const has_more = offset + limit < filtered.length
    return { options, has_more }
  }
</script>

<label><input type="checkbox" bind:checked={fail_next} /> Fail the next request</label>
<MultiSelect {load_options} placeholder="Search 10,000 items..." />

The component handles the required state management:

  • Loading initial options when dropdown opens
  • Loading more as user scrolls
  • Debounced search with automatic reset
  • Canceling superseded requests through signal
  • Loading indicators and a Retry button after failures; previously loaded options remain available

Simulated User API

This runnable example searches an in-memory user list; it does not contact a server. Use the REST recipe below to connect your own backend:

svelte<script lang="ts">
  import { MultiSelect } from 'svelte-widgets'
  import type { LoadOptionsParams, LoadOptionsResult, ObjectOption } from 'svelte-widgets'

  interface User extends ObjectOption {
    email: string
    id: number
  }

  // Simulated API
  async function fetch_users({
    search,
    offset,
    limit,
    signal,
  }: LoadOptionsParams): Promise<LoadOptionsResult<User>> {
    await new Promise((resolve) => setTimeout(resolve, 400))
    signal?.throwIfAborted()

    // Simulated database
    const all_users: User[] = Array.from({ length: 2000 }, (_, idx) => ({
      label: `User ${idx + 1}`,
      email: `user${idx + 1}@example.com`,
      id: idx + 1,
    }))

    const filtered = search
      ? all_users.filter(
          (user) =>
            user.label.toLowerCase().includes(search.toLowerCase()) ||
            user.email.toLowerCase().includes(search.toLowerCase()),
        )
      : all_users

    return {
      options: filtered.slice(offset, offset + limit),
      has_more: offset + limit < filtered.length,
    }
  }
</script>

<MultiSelect load_options={fetch_users} placeholder="Search users by name or email...">
  {#snippet children({ option })}
    <div>
      <strong>{option.label}</strong>
      <small style="opacity: 0.7; margin-left: 8px">{option.email}</small>
    </div>
  {/snippet}
</MultiSelect>

REST API Recipe

This complete component expects your endpoint to return { items: string[], total: number }. Adapt the endpoint and response type to your API. HTTP failures must throw: fetch resolves normally for responses such as 404 or 500.

<script lang="ts">
  import {
    MultiSelect,
    type LoadOptionsParams,
    type LoadOptionsResult,
  } from 'svelte-widgets'

  async function load_options({
    search,
    offset,
    limit,
    signal,
  }: LoadOptionsParams): Promise<LoadOptionsResult<string>> {
    const query = new URLSearchParams({
      q: search,
      skip: String(offset),
      take: String(limit),
    })
    const response = await fetch(`/api/items?${query}`, { signal })
    if (!response.ok) throw new Error(`Loading items failed: HTTP ${response.status}`)
    const { items, total }: { items: string[]; total: number } = await response.json()
    return { options: items, has_more: offset + items.length < total }
  }
</script>

<MultiSelect {load_options} placeholder="Search remote items..." />

URLSearchParams preserves punctuation such as & and # within the search term. Forward signal so a new search, closing the dropdown, or unmounting cancels the old fetch. Let errors propagate so the component can show Retry; returning an empty array from a catch would hide the failure. A successful empty response is { items: [], total: 0 } and shows the normal no-matches message without Retry.

Configuration Options

For advanced control, pass an object with fetch function and config:

svelte<script lang="ts">
  import { MultiSelect } from 'svelte-widgets'
  import type { LoadOptionsParams, LoadOptionsResult } from 'svelte-widgets'

  const all_items: string[] = Array.from({ length: 500 }, (_, idx) => `Option ${idx + 1}`)

  async function load_options(
    params: LoadOptionsParams,
  ): Promise<LoadOptionsResult<string>> {
    const { search, offset, limit, signal } = params
    await new Promise((resolve) => setTimeout(resolve, 200))
    signal?.throwIfAborted()

    const filtered = search
      ? all_items.filter((item) => item.toLowerCase().includes(search.toLowerCase()))
      : all_items

    return {
      options: filtered.slice(offset, offset + limit),
      has_more: offset + limit < filtered.length,
    }
  }
</script>

<MultiSelect
  load_options={{ fetch: load_options, debounce_ms: 500, batch_size: 20 }}
  placeholder="Custom config (500ms debounce, 20 items per batch)"
/>

Object Options with Custom Display

Use object options with custom snippets:

svelte<script lang="ts">
  import { MultiSelect } from 'svelte-widgets'
  import type { LoadOptionsParams, LoadOptionsResult, ObjectOption } from 'svelte-widgets'

  interface Language extends ObjectOption {
    year: number
  }

  const all_languages: Language[] = [
    { label: `JavaScript`, year: 1995 },
    { label: `TypeScript`, year: 2012 },
    { label: `Python`, year: 1991 },
    { label: `Rust`, year: 2010 },
    { label: `Go`, year: 2009 },
    { label: `Java`, year: 1995 },
    { label: `C++`, year: 1985 },
    { label: `C#`, year: 2000 },
    { label: `Ruby`, year: 1995 },
    { label: `Swift`, year: 2014 },
  ]

  async function load_options(
    params: LoadOptionsParams,
  ): Promise<LoadOptionsResult<Language>> {
    const { search, offset, limit, signal } = params
    await new Promise((resolve) => setTimeout(resolve, 200))
    signal?.throwIfAborted()

    const filtered = search
      ? all_languages.filter((lang) =>
          lang.label.toLowerCase().includes(search.toLowerCase()),
        )
      : all_languages

    return {
      options: filtered.slice(offset, offset + limit),
      has_more: offset + limit < filtered.length,
    }
  }
</script>

<MultiSelect {load_options} placeholder="Search languages...">
  {#snippet children({ option })}
    <span>{option.label} <small style="opacity: 0.6">({option.year})</small></span>
  {/snippet}
</MultiSelect>

Lazy Loading on Open

By default, options load when the dropdown opens. Set on_open: false to disable:

Selected: none

svelte<script lang="ts">
  import { MultiSelect } from 'svelte-widgets'
  import type { LoadOptionsParams, LoadOptionsResult } from 'svelte-widgets'

  const items: string[] = Array.from({ length: 100 }, (_, idx) => `Item ${idx + 1}`)

  async function load_options(
    params: LoadOptionsParams,
  ): Promise<LoadOptionsResult<string>> {
    const { search, offset, limit, signal } = params
    await new Promise((resolve) => setTimeout(resolve, 300))
    signal?.throwIfAborted()
    const filtered = search
      ? items.filter((item) => item.toLowerCase().includes(search.toLowerCase()))
      : items
    return {
      options: filtered.slice(offset, offset + limit),
      has_more: offset + limit < filtered.length,
    }
  }

  let selected: string[] = $state([])
</script>

<MultiSelect
  load_options={{ fetch: load_options, on_open: false }}
  bind:value={selected}
  placeholder="Type to search (won't load on open)..."
/>

<p>Selected: {selected.join(`, `) || `none`}</p>

Props Reference

The load_options prop accepts either a function (simple) or an object (with config):

// Function shorthand
load_options={myFetchFn}

// With config: object with fetch + options
load_options={{ fetch: myFetchFn, debounce_ms: 500, batch_size: 20, on_open: false }}
Config KeyTypeDefaultDescription
fetchfnAsync function to load options (required)
debounce_msnumber300Debounce delay for search queries
batch_sizenumber50Number of options to load per batch
on_openbooleantrueWhether to load options when dropdown opens

LoadOptions Parameters

interface LoadOptionsParams {
  search: string // Current search text
  offset: number // Number of options already loaded (for pagination)
  limit: number // Batch size to load
  signal?: AbortSignal // Aborted when a request is superseded or closed
}

interface LoadOptionsResult<T> {
  options: T[] // Array of options to add
  has_more: boolean // Whether more options are available
  replace?: boolean // Replace loaded options with this ordered snapshot
  error?: Error // Show partial results alongside Retry
}

Cursor-based pagination

For cursor APIs, keep the next cursor in a closure. An offset of 0 starts a fresh search or a reopened dropdown. This example expects { items: string[], next_cursor: string | null }, where null means the last page.

import type { LoadOptionsParams, LoadOptionsResult } from 'svelte-widgets'

function make_load_options() {
  let cursor: string | null = null

  return async ({
    search,
    offset,
    limit,
    signal,
  }: LoadOptionsParams): Promise<LoadOptionsResult<string>> => {
    if (offset === 0) cursor = null
    const params = new URLSearchParams({ q: search, limit: String(limit) })
    if (cursor !== null) params.set(`cursor`, cursor)
    const response = await fetch(`/api/items?${params}`, { signal })
    if (!response.ok) throw new Error(`Loading items failed: HTTP ${response.status}`)
    const { items, next_cursor }: { items: string[]; next_cursor: string | null } =
      await response.json()
    // Check after the final await before updating shared pagination state.
    signal?.throwIfAborted()
    cursor = next_cursor
    return { options: items, has_more: next_cursor !== null }
  }
}

The component aborts superseded requests. Checking the signal after parsing prevents a late response from overwriting the current search’s cursor, even if the request transport ignored cancellation. Failed requests leave the cursor unchanged, so Retry requests the same page. Each non-final cursor page must contain options: an empty batch stops automatic pagination to avoid a request loop.

Create one loader per MultiSelect instance and pass the same reference; changing the fetch identity resets the loaded batch:

<script lang="ts">
  import { MultiSelect } from 'svelte-widgets'
  // Define make_load_options from the recipe above in this script.
  const load_options = make_load_options()
</script>

<MultiSelect {load_options} />

Error Handling

If load_options throws or rejects, the component displays an error and a Retry button while keeping previously loaded options available. Retry repeats the failed request; changing the search starts a new request.

For partial failures, return successful options with error. Retry passes the number of loaded options as offset, so retain failed items in the loader and retry those downloads. Return replace: true with the full ordered result snapshot to restore their original positions without appending duplicates. Retry remains available even when has_more is false.