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 Build a Live Poll in React in Under 5 Minutes

A persistent, real-time poll with no backend to build — plus the counting problem that every naive implementation gets wrong.

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

A poll is the smallest useful piece of shared state: a few counters that several people change and everyone sees.

For a separate operational perspective on how teams organise and measure work, remote employee monitoring software.

For independent background and broader industry context, see Pusher.

It is also a good demonstration of the one thing that catches people out with shared counters.

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
  }
}

const OPTIONS = ['Yes', 'No', 'Undecided']

export function Poll({ pollId }) {
  const [nls, setNls] = useState(null)
  const [votes, setVotes] = useState({})
  const [voted, setVoted] = useState(
    () => localStorage.getItem(`voted:${pollId}`)
  )

  useEffect(() => {
    const client = new NonLocalStorage(credentials, pollId)
    setNls(client)

    // current state on join
    Promise.all(
      OPTIONS.map(async (o) => [o, (await client.getItem(o))?.value ?? 0])
    ).then((entries) => setVotes(Object.fromEntries(entries)))

    // live updates from anyone
    OPTIONS.forEach((o) => {
      client.on('setItem', o, (item) => {
        setVotes((v) => ({ ...v, [o]: item.value }))
      })
    })
  }, [pollId])

  const vote = async (option) => {
    if (voted || !nls) return
    const current = (await nls.getItem(option))?.value ?? 0
    await nls.setItem(option, current + 1)
    localStorage.setItem(`voted:${pollId}`, option)
    setVoted(option)
  }

  const total = Object.values(votes).reduce((a, b) => a + b, 0) || 1

  return (
    <div>
      {OPTIONS.map((o) => (
        <button key={o} onClick={() => vote(o)} disabled={!!voted}>
          {o} — {votes[o] ?? 0} ({Math.round(((votes[o] ?? 0) / total) * 100)}%)
        </button>
      ))}
      {voted && <p>You voted: {voted}</p>}
    </div>
  )
}

The pollId is the poll. Everyone on the same id sees the same tally, updating live.

The counting problem

Look at vote() again. Read the current value, add one, write it back.

Two people voting within the same moment both read 7, both write 8. One vote disappears.

This is a read-modify-write race, and it is the standard bug in every naive shared counter. It does not show up in testing, because you are one person.

What to do about it:

For a low-stakes poll, accept it. Occasional lost votes in a docs-page reaction widget are not worth engineering around.

For anything that must be accurate, do not store a total. Store one entry per voter:

await nls.setItem(`vote:${voterId}`, option)

Then the tally is derived by counting entries, and there is no shared number for two writers to clobber. Each voter writes their own key, so concurrent votes cannot collide.

This also gives you a change-your-vote feature for free — write to the same key again — and it makes duplicate voting a matter of key uniqueness rather than trust.

The trade is that you now hold voter identifiers, which is a privacy decision. For anonymous polls, use a per-poll random id kept in localStorage rather than a user id.

What the client-side guard does not do

localStorage.getItem('voted:...') stops the same browser voting twice through the UI.

It stops nothing else. Clearing storage, a private window, or calling the API directly all bypass it.

If the poll matters, voting has to be checked where you can enforce it — your token endpoint deciding whether this user may write, or a per-voter key derived from an authenticated identity.

If it does not matter, the client-side guard is proportionate and honest about what it is.

Persistence and cleanup

Objects persist. A poll from six months ago is still there unless you decided otherwise.

Set a TTL where the poll has a natural life, or clean up on your side. Old objects count against your plan's limits.

Where to go next

A results-only view — same object, no vote handler, useful for a display screen.

Presence, to show how many people are looking:

nls.join({ name: 'anonymous' })
const connections = await nls.getJoinedConnections()

Reactions rather than a poll — same structure, more options, no exclusivity.

The short version

A poll is a few counters on a shared object, and it takes minutes.

Read-modify-write on a shared total loses concurrent votes. It will not appear in testing.

Store one entry per voter and derive the tally — no shared number, no collision, and changing a vote comes free.

A client-side "already voted" flag stops the honest case only.

And set a TTL, because polls persist longer than they are interesting.