dexie-reactive
v1.4.0
Published
Shared, SSR-safe Dexie live query state for Vue 3 and Nuxt 3.
Downloads
541
Maintainers
Readme
dexie-reactive
Shared, SSR-safe Dexie live query state for Vue 3 and Nuxt 3.
dexie-reactive wraps Dexie liveQuery with small Vue composables. One
component owns the real Dexie subscription as a producer. Other components can
subscribe to the same reactive state by key without creating another Dexie
subscription.
Contents
- Live Demo
- Why dexie-reactive?
- Quick Start
- Core Model
- Public API
- Lifecycle Overview
- Architecture Decision Records
- Testing Strategy
Live Demo
See how multiple Vue components share the same reactive Dexie liveQuery state without creating duplicate subscriptions.
The demo showcases:
- producer/consumer live query ownership
- shared reactive IndexedDB state
- Vue 3 composables
- automatic cross-component updates
👉 Open the interactive StackBlitz demo
Why dexie-reactive?
Use dexie-reactive when multiple Vue components need to react to the same
Dexie query result without each component creating its own IndexedDB
subscription.
It provides:
- one producer-owned Dexie
liveQueryper key - shared Vue reactive state for all consumers
- duplicate producer protection
- SSR-safe client-only subscription behavior
- same-origin tab-to-tab updates through Dexie live query propagation
- persistent IndexedDB-backed state for query results without keeping the whole database in memory
- stale result protection during restarts and component cleanup
- explicit loading and error state
- focused unit and browser integration test coverage
This keeps IndexedDB reactivity predictable in Vue and Nuxt apps while leaving Dexie query construction fully under application control.
Decision Guide
| Use this package when | Avoid it when | | ----------------------------------------------------------------------------- | ------------------------------------------------------------- | | multiple components share the same IndexedDB query result | each component owns completely independent queries | | you want explicit producer and consumer ownership | you want implicit global query caching | | you need Nuxt-safe client-only IndexedDB behavior | you need server-side IndexedDB execution | | you want Dexie queries to stay application-owned | you want a query builder or ORM abstraction | | duplicate shared subscription ownership should fail immediately | duplicate subscriptions are acceptable in your design | | you want IndexedDB-backed state that behaves like persistent shared app state | you need a general-purpose Pinia replacement for all UI state | | you need same-origin tab-to-tab updates from Dexie writes | you need cross-device or backend synchronization |
What It Does Not Do
- It does not replace Dexie.
- It does not replace Pinia for transient UI state.
- It does not build or parse queries.
- It does not sync data to a backend.
- It does not sync data across devices, users, or origins.
- It does not provide persistence beyond IndexedDB.
- It does not create server-side data fetching.
- It does not hide duplicate ownership mistakes.
When To Use It
Use this package when a Vue or Nuxt app needs IndexedDB data that updates reactively after Dexie writes.
- Use
useLiveQueryin the component or composable that owns the query. - Use
useLiveQuerySubscriptionin components that only need to display the already shared state. - Use an explicit
keywhen multiple components should share one subscription. - Omit the key for local, non-shared live queries.
Installation
npm install dexie-reactive dexie vuedexie and vue are peer dependencies. Nuxt users can install the package in a
Nuxt 3 project and import the composables directly from dexie-reactive.
Quick Start
Define your Dexie database once:
// db.ts
import Dexie, { type EntityTable } from 'dexie'
export interface Friend {
id?: number
name: string
age: number
}
export interface AppDatabase extends Dexie {
friends: EntityTable<Friend, 'id'>
}
export const db = new Dexie('app-db') as AppDatabase
db.version(1).stores({
friends: '++id,name,age',
})Create a producer composable:
// useOlderFriends.ts
import { useLiveQuery } from 'dexie-reactive'
import { db } from './db'
export function useOlderFriends() {
return useLiveQuery(() => db.friends.where('age').above(75).toArray(), {
key: 'older-friends',
})
}Use it in a producer component:
<script setup lang="ts">
import { useOlderFriends } from './useOlderFriends'
const { data, loading, hasError } = useOlderFriends()
</script>
<template>
<p v-if="loading">Loading...</p>
<p v-else-if="hasError">Could not load friends.</p>
<ul v-else>
<li v-for="friend in data" :key="friend.id">
{{ friend.name }}
</li>
</ul>
</template>Use the same state from a consumer component:
<script setup lang="ts">
import { useLiveQuerySubscription } from 'dexie-reactive'
import type { Friend } from './db'
const { data, loading, hasError } =
useLiveQuerySubscription<Friend>('older-friends')
</script>
<template>
<p v-if="loading">Loading...</p>
<p v-else-if="hasError">Could not load friends.</p>
<ul v-else>
<li v-for="friend in data" :key="friend.id">
{{ friend.name }}
</li>
</ul>
</template>Core Model
useLiveQuery is the producer. It owns the real Dexie live query subscription.
useLiveQuerySubscription is the consumer. It attaches to shared state by key
and never creates a Dexie subscription.
An active consumer receives the shared reactive refs by key. It keeps local
controls so stop() and restart() only affect that consumer attachment, not
the producer-owned Dexie subscription.
For detailed producer and consumer states, cleanup, and reattach flows, see Live Query Lifecycle.
Architecture Decision Records
- 0001: Consumer Lifecycle After Producer Cleanup defines the decision space for consumers that outlive their producer, including current behavior, consumer handover, and re-waiting with a snapshot.
Public API
The package exports only the stable composables and public types:
import {
useLiveQuery,
useLiveQuerySubscription,
type LiveQueryState,
type UseLiveQueryOptions,
} from 'dexie-reactive'useLiveQuery(queryFn, options?)
Creates and owns a Dexie live query subscription.
function useLiveQuery<T>(
queryFn?:
| (() => T[] | Promise<T[]>)
| Ref<(() => T[] | Promise<T[]>) | null | undefined>
| null,
options?: {
key?: string
},
): LiveQueryState<T>queryFnis a function that returns an array or a promise of an array.queryFnmay be passed directly or as a reactive function reference.options.keyis optional. If omitted, a UUID key is generated and returned.- Only one producer may exist for a key.
- The live query is created only in the browser.
useLiveQuerySubscription(key)
Consumes existing shared state by key.
function useLiveQuerySubscription<T>(key: string): LiveQueryState<T>- Receives only a key.
- Never receives a query function.
- Never creates a Dexie live query subscription.
- Returns the same reactive refs as the producer once the producer exists.
- Uses local
stop()andrestart()controls for its own attachment.
Returned State
Both composables return:
interface LiveQueryState<T> {
key: string
data: Ref<T[]>
loading: Ref<boolean>
hasError: Ref<boolean>
error?: Ref<unknown | undefined>
stop: () => void
restart: () => void
}error is available only in development mode. Production consumers should rely
on hasError.
Generated Key Usage
If no key is provided, useLiveQuery generates a UUID key and returns it.
const friends = useLiveQuery(() => db.friends.toArray())
console.log(friends.key)Generated keys are useful for local, non-shared subscriptions. Use an explicit key when another component needs to subscribe to the same state.
Error Handling
Errors are caught internally and do not throw to components.
const friends = useLiveQuery(() => db.friends.toArray(), {
key: 'friends',
})
if (friends.hasError.value) {
// Render fallback UI or trigger app-level reporting.
}On failure:
hasError.valuebecomestrueloading.valuebecomesfalsedata.valueremains an array- the original error is exposed only through
errorin development mode
Query Function Behavior
The package passes your function to Dexie liveQuery; it does not parse, build,
transform, or interpret Dexie queries.
const query = () => db.friends.where('age').above(75).toArray()
const friends = useLiveQuery(query, { key: 'older-friends' })Reactive values used inside the query function are not tracked by
dexie-reactive. If external dependencies change, recreate the query function
reference.
import { computed } from 'vue'
const minimumAge = ref(75)
const query = computed(
() => () => db.friends.where('age').above(minimumAge.value).toArray(),
)
const friends = useLiveQuery(query, { key: 'filtered-friends' })When the query function reference changes, the composable stops the current
subscription, resets state to data = [], loading = true, hasError = false,
and starts a new subscription.
SSR And Nuxt
The same package build works in plain Vue and Nuxt.
During SSR:
- no Dexie live query subscription is created
- shared state is scoped per runtime environment
- browser state is not shared with SSR
- SSR request state is isolated and cannot leak across requests
Use the composables in Nuxt components or composables, but expect live Dexie updates only on the client because IndexedDB is a browser API.
<script setup lang="ts">
import { useLiveQuery } from 'dexie-reactive'
const friends = useLiveQuery(() => db.friends.toArray(), {
key: 'friends',
})
</script>Lifecycle Controls
Producer controls from useLiveQuery manage the real Dexie subscription:
stop()unsubscribes from Dexie, setsloadingtofalse, and keeps current data.restart()performs a full reset and starts a new subscription using the latest query function.
Consumer controls from useLiveQuerySubscription manage only that consumer's
local attachment:
stop()detaches the consumer, keeps a snapshot of current data, and sets localloadingtofalse.restart()reattaches the consumer to the current shared state for the same key, or waits again if no producer exists.
Consumer controls never stop, restart, or create the Dexie live query. If you
previously called consumer controls to manage the shared subscription, call the
producer controls returned by useLiveQuery instead.
Lifecycle Overview
useLiveQuery creates one shared live query state for a key and owns the Dexie
subscription behind it. useLiveQuerySubscription consumes that state by key
without creating another Dexie subscription.
Consumers can mount before or after the producer. If no producer exists, a consumer waits for the key and attaches when the producer registers. Duplicate producers for the same key fail immediately so subscription ownership stays explicit.
If a producer is cleaned up while a consumer remains mounted, the consumer keeps its last data snapshot, waits for the same key, and reattaches when a replacement producer registers.
For detailed lifecycle diagrams covering producer-first, consumer-first, producer cleanup, snapshot waiting, reattach behavior, and stopped consumers, see Live Query Lifecycle.
Limitations
- Dexie queries must return arrays.
- Consumers cannot create subscriptions.
- Keys are identifiers for shared state, not a security boundary.
- Live queries run only in the browser.
- The package uses vanilla Dexie
liveQuerysemantics through the composables. - It does not use framework-specific Dexie bindings such as React hooks.
Anti-Patterns
Do not create duplicate producers for the same key:
useLiveQuery(() => db.friends.toArray(), { key: 'friends' })
useLiveQuery(() => db.friends.toArray(), { key: 'friends' }) // throwsDo not pass query functions to consumers:
useLiveQuerySubscription('friends') // correct
useLiveQuerySubscription(() => db.friends.toArray()) // incorrectDo not rely on reactive values inside a stable query function reference:
const minimumAge = ref(75)
useLiveQuery(() => db.friends.where('age').above(minimumAge.value).toArray(), {
key: 'friends',
})Recreate the query function when dependencies change instead.
Demo And Browser Integration Test
The browser test app doubles as a small local demo:
npm run test:browser:serverOpen the printed local URL and inspect IndexedDB under that same origin. The demo
database is named dexie-reactive-browser and uses a friends object store.
Testing Strategy
The unit test suite focuses on the shared live query contract:
- public API exports and returned reactive state shape
- browser singleton and SSR-isolated subscription scopes
- producer lifecycle for start, stop, restart, unsubscribe, and cleanup
- duplicate producer rejection without creating a second Dexie subscription
- consumer coordination for producer-first, waiting, cleanup, and reattach flows
- shared reactive state references instead of cloned consumer state
- stale result protection across stop, restart, scope disposal, and rapid query changes
- missing, invalid, and changing query function handling
- error, loading, and development-only error exposure behavior
- generated UUID key uniqueness
- Dexie
liveQueryusage through the provided query callback
The browser integration suite mounts a minimal Vue app in Chromium with a real Dexie IndexedDB database. It verifies producer and consumer components sharing one key, database updates propagating to all mounted components, producer and consumer unmount/remount behavior, reattach after producer cleanup, and duplicate producer errors in the browser runtime.
Scripts
npm run lintchecks the code with ESLint.npm run format:checkverifies Prettier formatting.npm run typecheckruns TypeScript without emitting files.npm run testruns Vitest tests fromtests/*.npm run test:browserruns Playwright browser integration tests.npm run buildbuilds the package with unbuild.npm run checkruns linting, formatting, type checking, tests, and build.
Git Hooks
Husky runs staged linting before commits and commitlint for commit messages.
