Skip to content
Convex Pulse
Esc
navigateopen⌘Jpreview
On this page

Svelte

Reactive Svelte queries, context, authentication, mutations, prefetching, and cleanup.

Setup

For a new plain Svelte application, scaffold with Vite and initialize Convex:

pnpm create vite my-app --template svelte-ts
cd my-app
pnpm add convex-pulse convex
pnpm exec convex dev

Add the backend from Before you start. The first convex dev run generates convex/_generated/api and writes VITE_CONVEX_URL to .env.local after you create or select a deployment.

Initialize one app-scoped client in the root component. setupConvex reuses an early client created by SvelteKit’s SSR transport and places it in Svelte context.

<script lang="ts">
  import { setupConvex } from 'convex-pulse/svelte'

  let { children } = $props()
  setupConvex(import.meta.env.VITE_CONVEX_URL)
</script>

{@render children()}

Use useConvexClient() in child components or getConvexClient() in non-component browser code. closeConvex() is intended for tests or an application host teardown, not route navigation.

Reactive queries

useQuery(reference, argsGetter, options?) tracks every reactive value read by the argument getter. It replaces the subscription when those values change and releases it when the component is destroyed.

<script lang="ts">
  import { useQuery } from 'convex-pulse/svelte'
  import { api } from '../convex/_generated/api'

  let listId = $state<string | undefined>()
  const tasks = useQuery(api.tasks.list, () =>
    listId === undefined ? 'skip' : { listId }
  )
</script>

{#if tasks.isLoading}
  <p>Loading…</p>
{:else if tasks.error}
  <p>{tasks.error.message}</p>
{:else}
  {#each tasks.data ?? [] as task (task._id)}
    <p>{task.title}</p>
  {/each}
{/if}

The literal 'skip' is the shared real skip token and permits genuinely missing arguments without fabricating a fallback. Options include initialData, keepPreviousData, onDataChange, retries, select, and throwOnError. usePaginatedQuery provides the same reactive argument and skip behavior plus initialNumItems, canLoadMore, and loadMore.

The createQuery(client, reference, options) APIs below remain available as lower-level readable stores for code that owns a client explicitly.

Complete client component

<script lang="ts">
  import {
    useAction,
    useMutation,
    usePrefetchQuery,
    useQuery
  } from 'convex-pulse/svelte'
  import { onDestroy } from 'svelte'

  import { api } from '../convex/_generated/api'

  const listId = 'default'
  let title = $state('Svelte task')
  const tasks = useQuery(api.tasks.list, () => ({ listId }), {
    onDataChange: ({ next, previous }) => {
      console.log('Tasks changed', { next, previous })
    }
  })
  const createTask = useMutation(api.tasks.create)
  const formatTask = useAction(api.tasks.format)
  const prefetchTask = usePrefetchQuery(api.tasks.get)
  let cancelPrefetch: (() => void) | undefined

  onDestroy(() => {
    cancelPrefetch?.()
  })

  function warmTask(id: Parameters<typeof prefetchTask>[0]['id']) {
    cancelPrefetch?.()
    const handle = prefetchTask({ id })
    cancelPrefetch = handle.cancel
    void handle.ready.catch(() => undefined)
  }
</script>

<input aria-label="Task title" bind:value={title} />
<button
  disabled={$createTask.isPending}
  onclick={() =>
    void createTask({ listId, title }).catch(() => undefined)}
>
  Create task
</button>
<button
  disabled={$formatTask.isPending}
  onclick={() => void formatTask({ title }).catch(() => undefined)}
>
  Format title
</button>

{#if tasks.status === 'pending'}
  <p>Loading…</p>
{:else if tasks.status === 'error'}
  <p>{tasks.error.message}</p>
{:else}
  {#each tasks.data as task (task._id)}
    <button type="button" onmouseenter={() => warmTask(task._id)}>
      {task.title}
    </button>
  {/each}
{/if}

createQuery

createQuery(client, reference, options) returns a readable store. Its query subscription exists while the store has subscribers.

Fetching and selecting data

<script lang="ts">
  import { createQuery } from 'convex-pulse/svelte'
  import { api } from '../convex/_generated/api'
  import { convex } from './convex'

  const openTasks = createQuery(convex, api.tasks.list, {
    args: { listId },
    select: (tasks) => tasks.filter((task) => !task.done)
  })
</script>

Arguments go under args. Pass current Convex values, not stores. Arguments are captured when createQuery runs; changing an argument store later does not replace the prepared query. Only enabled accepts a readable store. select derives the successful data value.

Handling query state

{#if $openTasks.status === 'pending'}
  <p>Loading…</p>
{:else if $openTasks.status === 'error'}
  <p>{$openTasks.error.message}</p>
{:else if $openTasks.status === 'success'}
  {#each $openTasks.data as task (task._id)}
    <p>{task.title}</p>
  {/each}
{/if}

With no enabled option, the store type contains pending, error, or success; TypeScript correctly excludes the unreachable disabled branch. Passing a boolean or readable enabled option adds disabled to the result union.

Disabling and retrying

enabled accepts a boolean or readable store. retries is the number of additional attempts.

const task = createQuery(convex, api.tasks.get, {
  args: { id },
  enabled: hasTaskId,
  retries: 2
})

Observing data changes

Pass onDataChange in the options or import and call the standalone helper. It returns an unsubscribe function that a component should release with onDestroy. Listeners receive { next, previous } after the initial successful result changes.

<script lang="ts">
  import { onDataChange } from 'convex-pulse/svelte'
  import { onDestroy } from 'svelte'

  const stopObserving = onDataChange(tasks, listener)
  onDestroy(stopObserving)
</script>

Loading paginated data

const tasks = createQuery(convex, api.tasks.listPaginated, {
  args: { listId },
  pagination: { initialNumItems: 20 }
})

Successful pagination state exposes canLoadMore, isLoading, isLoadingMore, and loadMore(numItems). Paginated queries do not support select.

createMutation

Executing and observing a mutation

const createTask = createMutation(convex, api.tasks.create, {
  onSuccess: ({ data }) => console.log('Created', data),
  retries: 1
})

await createTask({ listId, title: 'Svelte docs' })
console.log(createTask.status, createTask.isPending)

The result is both callable and readable. Imperative code reads createTask.status, createTask.data, createTask.error, and createTask.isPending. In Svelte markup, use $createTask.status and the other $createTask properties so the component subscribes reactively. reset() and mutation lifecycle callbacks are available on the callable.

Deduplicating and updating optimistically

const createTask = createMutation(convex, api.tasks.create, {
  dedupe: ({ args }) => args.title,
  optimistic: ({ data, optimisticId, store }) =>
    store.get(api.tasks.list, { listId: data.listId }).append({
      _id: optimisticId,
      _creationTime: Date.now(),
      done: false,
      listId: data.listId,
      title: data.title
    })
})

createAction

createAction(client, reference, options?) returns a typed callable that is also a readable lifecycle-state store.

Executing and retrying an action

const formatTask = createAction(convex, api.tasks.format, {
  retries: 2 // Only safe when this action is idempotent.
})
const formatted = await formatTask({ title: 'Svelte docs' })

Set dedupe: ({ args }) => args.title in the action options to share one in-flight request between concurrent calls with the same value. The key is released when the action settles. Deduplication is local to this client instance, so it is not durable idempotency and does not make retries safe.

Action retries are disabled by default (retries: 0). Only set retries above zero when the action is idempotent or implements its own idempotency key. Actions can perform external side effects, and a failed attempt may have completed those effects before reporting the failure; retrying can therefore repeat them.

Imperative code reads formatTask.status, formatTask.data, formatTask.error, and formatTask.isPending, and can call formatTask.reset(). Use $formatTask.status and the other $formatTask properties in Svelte markup for reactive updates.

createPrefetchQuery

createPrefetchQuery(client, reference) returns a typed prefetch function. Each call returns { ready, cancel }; canceling an unfinished prefetch rejects ready with an AbortError.

<script lang="ts">
  import type { Id } from '../convex/_generated/dataModel'

  const prefetchTask = createPrefetchQuery(convex, api.tasks.get)
  let cancelPrefetch: (() => void) | undefined

  onDestroy(() => cancelPrefetch?.())

  function warmTask(id: Id<'task'>) {
    cancelPrefetch?.()
    const handle = prefetchTask({ id })
    cancelPrefetch = handle.cancel
    void handle.ready.catch(() => undefined)
  }
</script>

createPreloadedQuery

createPreloadedQuery(client, payload) returns a readable store for a query fetched during server rendering. It starts in success with the embedded server result and publishes live values or errors after subscribing.

A plain Vite Svelte application has no server load phase, so it normally uses createQuery. Use createPreloadedQuery with SvelteKit or another server framework that can produce and serialize the payload.

<script lang="ts">
  import { PUBLIC_CONVEX_URL } from '$env/static/public'
  import {
    ConvexPulseSvelteClient,
    createPreloadedQuery
  } from 'convex-pulse/svelte'
  import type { PageProps } from './$types'

  let { data }: PageProps = $props()
  const client = new ConvexPulseSvelteClient(PUBLIC_CONVEX_URL)
  const tasks = $derived(createPreloadedQuery(client, data.preloadedTasks))

  $effect(() => () => void client.close())
</script>

{#if $tasks.status === 'error'}
  <p>{$tasks.error.message}</p>
{:else}
  {#each $tasks.data as task (task._id)}
    <p>{task.title}</p>
  {/each}
{/if}

Create the payload on the server with ConvexPulseHttpClient.preloadQuery(). See the complete SvelteKit setup, including imports, environment configuration, request-scoped authentication, serialization, and page-scoped cleanup.

Authentication

First configure your provider’s JWT issuer and audience in convex/auth.config.ts by following the official Convex authentication guides. Convex Pulse does not create sessions or tokens; it sends the identity JWT returned by your auth SDK to Convex.

Call setupAuth beside setupConvex in the root layout. Its getter is reactive: sign-in, sign-out, loading, and token refresh changes automatically call setAuth or clearAuth on the app-scoped client.

<script lang="ts">
  import { setupAuth, setupConvex } from 'convex-pulse/svelte'

  setupConvex(import.meta.env.VITE_CONVEX_URL)
  setupAuth(() => ({
    isLoading: session.isLoading,
    isAuthenticated: session.user !== null,
    fetchAccessToken: ({ forceRefreshToken }) =>
      getToken({ skipCache: forceRefreshToken })
  }))
</script>

Child components call useAuth() to read reactive isLoading, isAuthenticated, and isRefreshing state, and can return 'skip' from query argument getters until authentication is ready. For SSR, pass initialState to seed the server render. Low-level client.setAuth and client.clearAuth remain available for custom integrations. For SvelteKit server-side authentication, use the request-scoped helper shown in the SvelteKit rendering guide.

Cleanup

Svelte automatically unsubscribes from stores referenced with $store when the component is destroyed. Release standalone onDataChange listeners and component-owned prefetches with onDestroy. A client may be page-scoped or app-scoped: close a page-scoped client when that page is destroyed, but keep an app-scoped shared client open across navigation and close it only with the application host. Closing cannot be reversed.

Direct client methods

ConvexPulseSvelteClient also exposes setAuth, clearAuth, action, prepareQuery, mutation, prefetch, devtools, and close.

Was this page helpful?