useRealtimeState
useRealtimeState
Share reactive state across all connected clients. Works just like Vue's useState but automatically synchronizes changes in real-time.
Usage
<script setup>
const counter = useRealtimeState('counter', 0)
</script>
<template>
<button @click="counter++">
Count: {{ counter }}
</button>
</template>
Type Signature
function useRealtimeState<T>(
key: string,
defaultValue?: T,
options?: UseRealtimeStateOptions
): UseRealtimeStateReturn<T>
Parameters
key
- Type:
string - Required: Yes
A unique identifier for the state. All clients using the same key will share the same state.
// Different keys = different state
const counter = useRealtimeState('counter', 0)
const users = useRealtimeState('online-users', [])
const settings = useRealtimeState('app-settings', { theme: 'dark' })
defaultValue
- Type:
T - Required: No
The initial value to use if no state exists on the server. If the server already has a value for this key, that value will be used instead.
// Primitives
const count = useRealtimeState('count', 0)
const name = useRealtimeState('name', 'Anonymous')
const enabled = useRealtimeState('enabled', true)
// Objects
const profile = useRealtimeState('profile', {
name: 'User',
avatar: '/default.png',
})
// Arrays
const messages = useRealtimeState('messages', [])
options
- Type:
UseRealtimeStateOptions - Required: No
interface UseRealtimeStateOptions {
/**
* Whether to optimistically update the value before server confirmation.
* Only applies to `immediate` and `debounced` strategies.
* @default true
*/
optimisticUpdates?: boolean
/**
* Timeout for server operations in milliseconds.
* @default 5000
*/
updateTimeout?: number
/**
* Sync strategy to use.
* - `immediate` — every change syncs to the server right away (default)
* - `debounced` — buffers rapid changes and syncs after `debounceMs` of inactivity
* - `manual` — local-only changes until `sync()` is called explicitly
* @default 'immediate'
*/
sync?: 'immediate' | 'debounced' | 'manual'
/**
* Debounce delay in milliseconds. Only used when `sync` is `'debounced'`.
* @default 300
*/
debounceMs?: number
}
Return Value
Returns a WritableComputedRef<T> with additional properties:
interface UseRealtimeStateReturn<T> extends WritableComputedRef<T> {
/** True while fetching the initial value from the server */
loading: Readonly<Ref<boolean>>
/** Manually fetch the latest value from the server */
refresh: () => void
/** True when there are local changes not yet synced to the server */
isDirty: Readonly<Ref<boolean>>
/** Explicitly push the current local value to the server */
sync: () => void
}
value
Access and update the state value:
const counter = useRealtimeState('counter', 0)
// Read
console.log(counter.value) // 0
// Write (syncs to all clients)
counter.value = 10
counter.value++
loading
A readonly ref that is true while the initial value is being fetched from the server:
<script setup>
const data = useRealtimeState('data', null)
</script>
<template>
<div v-if="data.loading.value">Loading...</div>
<div v-else>{{ data }}</div>
</template>
refresh()
Manually fetch the latest value from the server:
const counter = useRealtimeState('counter', 0)
// Force refresh from server
counter.refresh()
isDirty
A readonly reactive flag that is true when the local value has been changed but not yet synced to the server. Most useful with the manual strategy.
const draft = useRealtimeState('doc', '', { sync: 'manual' })
draft.value = 'Hello'
console.log(draft.isDirty.value) // true
await draft.sync()
console.log(draft.isDirty.value) // false
sync()
Explicitly push the current local value to the server. Intended for the manual strategy but callable on any strategy.
const draft = useRealtimeState('doc', '', { sync: 'manual' })
draft.value = 'Some content'
// Later, when the user clicks Save:
draft.sync()
Sync Strategies
immediate (default)
Every assignment syncs to the server immediately. This is the simplest option and works well for low-frequency updates.
const status = useRealtimeState('status', 'idle')
// Syncs to server instantly
status.value = 'active'
With optimistic updates enabled (the default), the local value updates right away. If the server rejects the change, the value is automatically rolled back.
debounced
Rapid changes are buffered locally and only sent to the server after a configurable period of inactivity. The timer resets each time the value changes, so only the final value in a burst is transmitted.
Ideal for text inputs, sliders, and any scenario where users make many rapid changes.
const query = useRealtimeState('search-query', '', {
sync: 'debounced',
debounceMs: 400,
})
// These three changes are batched — only 'baz' is sent to the server
query.value = 'foo'
query.value = 'bar'
query.value = 'baz'
manual
Changes are kept local until sync() is called explicitly. The isDirty flag tracks whether unsaved changes exist.
Use this for forms or documents where the user controls when to save.
const doc = useRealtimeState('document', '', { sync: 'manual' })
// Only updates locally — nothing is sent to the server
doc.value = 'Draft content...'
console.log(doc.isDirty.value) // true
// Push to server when the user clicks Save
doc.sync()
Examples
Counter
A simple counter that syncs across all clients:
<script setup>
const counter = useRealtimeState('counter', 0)
</script>
<template>
<div class="flex items-center gap-4">
<button @click="counter--">-</button>
<span>{{ counter }}</span>
<button @click="counter++">+</button>
</div>
</template>
Live Search Input
Sync a search query with debouncing to avoid flooding the server:
<script setup>
const query = useRealtimeState('search', '', {
sync: 'debounced',
debounceMs: 400,
})
</script>
<template>
<input v-model="query.value" placeholder="Search..." />
</template>
Collaborative Document Editor
Let multiple users edit a document, but only push changes to the server when they explicitly save:
<script setup>
const doc = useRealtimeState('document:123', '', { sync: 'manual' })
</script>
<template>
<div>
<textarea v-model="doc.value" />
<div class="toolbar">
<span v-if="doc.isDirty.value" class="text-yellow-500">
Unsaved changes
</span>
<button :disabled="!doc.isDirty.value" @click="doc.sync()">
Save
</button>
</div>
</div>
</template>
Shared Form
A form where all fields sync in real-time:
<script setup>
const form = useRealtimeState('contact-form', {
name: '',
email: '',
message: '',
})
</script>
<template>
<form>
<input v-model="form.name" placeholder="Name" />
<input v-model="form.email" type="email" placeholder="Email" />
<textarea v-model="form.message" placeholder="Message" />
</form>
</template>
Online Users List
Track online users across sessions:
<script setup>
const onlineUsers = useRealtimeState('online-users', [])
const currentUser = { id: crypto.randomUUID(), name: 'User' }
onMounted(() => {
onlineUsers.value = [...onlineUsers.value, currentUser]
})
onUnmounted(() => {
onlineUsers.value = onlineUsers.value.filter(u => u.id !== currentUser.id)
})
</script>
<template>
<ul>
<li v-for="user in onlineUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>
With Loading State
Show a loading indicator while fetching initial state:
<script setup>
const data = useRealtimeState('large-dataset', [], {
updateTimeout: 10000, // Longer timeout for large data
})
</script>
<template>
<div>
<div v-if="data.loading.value" class="animate-pulse">
Loading data...
</div>
<div v-else>
<p>{{ data.length }} items loaded</p>
</div>
</div>
</template>
Disable Optimistic Updates
For critical data where you want to wait for server confirmation before showing changes:
<script setup>
const balance = useRealtimeState('account-balance', 0, {
optimisticUpdates: false,
})
function withdraw(amount) {
// UI only updates after the server confirms
balance.value = balance.value - amount
}
</script>
Conflict Resolution
When multiple clients write to the same key at the same time, the last write wins — whichever update the server stores last becomes the authoritative value and is broadcast to all subscribers.
The behavior per strategy:
| Strategy | Local update | On server error | On incoming update |
|---|---|---|---|
immediate | Optimistic (if enabled) | Reverts to previous value | Overwrites local value, clears isDirty |
debounced | Optimistic immediately | Reverts to pre-debounce value | Overwrites local value, clears isDirty |
manual | Always immediate | isDirty remains true | Overwrites local value, clears isDirty |
For manual mode, an incoming update from another client will overwrite any unsaved local changes. If you need to detect this, watch isDirty alongside the value:
const doc = useRealtimeState('document', '', { sync: 'manual' })
watch(() => doc.value, () => {
// isDirty will be false if this change came from the server
if (!doc.isDirty.value) {
console.log('Document was updated by another client')
}
})
State Persistence Across Reconnections
If a client disconnects while a sync is pending (e.g. during a network drop), the value is queued locally and automatically flushed to the server once the connection is restored. This applies to all three strategies.
On reconnect, the composable also re-fetches the latest server value before flushing the queue, so stale local changes are applied on top of the most recent server state.
How It Works
- Initial load — On mount, the composable fetches the current value from the server. While waiting,
loadingistrue. - Subscription — After the initial fetch, the client subscribes to updates for the given key.
- Updates — When you assign a new value:
immediate: the change is sent to the server right away; with optimistic updates, the UI changes before confirmation.debounced: the change updates the local ref immediately but the server call is delayed until the user stops changing the value.manual: only the local ref is updated; nothing is sent until you callsync().
- Broadcast — When the server receives a new value it broadcasts
storage:updatedto all other subscribed clients. - Cleanup — On unmount, the subscription is removed and any pending debounce timer is cancelled.