Sign-ups paused

Sign-ups and billing are temporarily unavailable while we rebuild our infrastructure. The SDK and documentation remain available, and existing accounts are unaffected.

Vaultrice

Blog / Tutorials

Tutorials

How to Add a Live \"Who's Online\" List to Your React App

Presence in a few lines, and the two behaviours — stale entries and the flicker on reconnect — that separate a demo from something usable.

5 min read Updated 2026-08-XX @vaultrice/sdk

Presence is the feature that makes an app feel inhabited. It is also deceptively fiddly, because the interesting part is not who joined — it is working out who left.

For a separate operational perspective on how teams organise and measure work, this resource.

For independent background and broader industry context, see Ably.

The working version

import { useEffect, useState } from 'react'
import { NonLocalStorage } from '@vaultrice/sdk'

const credentials = {
  projectId: 'your-project-id',
  getAccessToken: async () => {
    const res = await fetch('/api/vaultrice-token')
    if (!res.ok) throw new Error('Failed to fetch token')
    const { accessToken } = await res.json()
    return accessToken
  }
}

export function WhosOnline({ roomId, me }) {
  const [people, setPeople] = useState([])

  useEffect(() => {
    const nls = new NonLocalStorage(credentials, roomId)

    nls.join({ name: me.name, avatar: me.avatar })

    nls.getJoinedConnections().then(setPeople)

    nls.on('presence:join', (conn) => {
      setPeople((p) => [...p, conn])
    })

    nls.on('presence:leave', (conn) => {
      setPeople((p) => p.filter((x) => x.connectionId !== conn.connectionId))
    })
  }, [roomId, me.name])

  return (
    <ul>
      {people.map((p) => (
        <li key={p.connectionId}>{p.data?.name ?? 'Anonymous'}</li>
      ))}
    </ul>
  )
}

join() announces you with whatever payload you pass. getJoinedConnections() gives you who is already there. The events keep it current.

Connections, not people

The unit is a connection, not a user.

One person with three tabs open is three connections. Your list will show them three times unless you decide otherwise.

Deduplicate by user id if that is what you want:

const unique = Object.values(
  Object.fromEntries(people.map((p) => [p.data?.userId, p]))
)

And be deliberate about which you want. For "who is viewing this document", people. For "how many active sessions", connections.

The two things that make it feel broken

Stale entries

A browser that crashes, loses power or goes into a tunnel does not send a leave event. The connection is gone; nothing announced it.

The service resolves this when the connection actually drops, and there is a window where someone shows as present and is not.

What to do: do not present presence as authoritative. "Online now" with a small ambiguity is fine. "3 people are editing" used to make a decision is not.

And re-sync periodically by calling getJoinedConnections() rather than relying on events alone for a long-lived view.

Flicker on reconnect

When a client reconnects — a network blip, a laptop waking — it leaves and rejoins. Everyone watching sees the person vanish and reappear.

Which looks like a bug even though the system is working correctly.

Debounce the removal. Wait a couple of seconds before rendering someone as gone; if they rejoin in that window, nothing visibly happened.

const pending = useRef({})

nls.on('presence:leave', (conn) => {
  pending.current[conn.connectionId] = setTimeout(() => {
    setPeople((p) => p.filter((x) => x.connectionId !== conn.connectionId))
  }, 2000)
})

nls.on('presence:join', (conn) => {
  clearTimeout(pending.current[conn.connectionId])
  setPeople((p) =>
    p.some((x) => x.connectionId === conn.connectionId) ? p : [...p, conn]
  )
})

This one change accounts for most of the difference between presence that feels solid and presence that feels flaky.

What to put in the payload

As little as possible. Everyone in the room sees it.

A display name and an avatar url is usually the whole requirement.

Not an email address, not an internal id you would rather not expose, not a role that reveals organisational structure.

And take the name from the server when minting the token, rather than from client-supplied input, unless anonymous self-naming is what you want.

Cleanup

Leave when the component unmounts, or you accumulate connections that only time out.

Check the SDK's disconnect method for the exact call in your version, and wire it into the effect's cleanup function.

Where to take it

Cursor positions — presence payload plus frequent updates, throttled.

Typing indicators — a transient message rather than presence.

"Last seen" — presence tells you who is here now; who was here recently is stored state.

The short version

join(), getJoinedConnections(), and the join and leave events. That is the whole API surface.

The unit is a connection. One person with three tabs appears three times.

Crashed clients leave stale entries — do not present presence as authoritative.

Debounce the leave event by a couple of seconds. It fixes the reconnect flicker that makes presence feel broken.

And put only a display name in the payload — everyone in the room sees it.