ExamplesCollaborative Todos

Collaborative Todos

A shared todo list synced in real time across all connected clients. When one user adds, toggles, or deletes a todo, all other users see it instantly.

Demonstrates: named event pub/sub (todo:created, todo:toggled, todo:deleted), optimistic updates, echo prevention

Source: examples/socket-hub/src/examples/CollaborativeTodos/


How it works

User A adds a todo
  → Optimistic: add to local state immediately
  → channel.publish('todo:created', todo)
  → All subscribers (except User A) receive todo:created event
  → User B, C... update their state

No server required. All state sync happens via WebSocket pub/sub.

Client code

import { useAerostack } from '@aerostack/react'
import { useEffect, useRef, useState } from 'react'
 
interface Todo {
  id: string
  text: string
  done: boolean
  createdBy: string
  createdAt: number
}
 
export function CollaborativeTodos() {
  const { realtime } = useAerostack()
  const [todos, setTodos] = useState<Todo[]>([])
  const [inputText, setInputText] = useState('')
 
  const myId = useRef(`user-${Math.random().toString(36).slice(2, 8)}`)
  const channelRef = useRef(null)
 
  useEffect(() => {
    const channel = realtime.channel('todos/shared-workspace')
    channelRef.current = channel
 
    channel
      .on('todo:created', ({ data, userId }) => {
        // Skip our own events (we added optimistically)
        if (userId === myId.current) return
        setTodos(prev => {
          if (prev.find(t => t.id === data.id)) return prev
          return [...prev, data]
        })
      })
      .on('todo:toggled', ({ data, userId }) => {
        if (userId === myId.current) return
        setTodos(prev => prev.map(t =>
          t.id === data.id ? { ...t, done: data.done } : t
        ))
      })
      .on('todo:deleted', ({ data, userId }) => {
        if (userId === myId.current) return
        setTodos(prev => prev.filter(t => t.id !== data.id))
      })
      .subscribe()
 
    return () => {
      channelRef.current = null
      channel.unsubscribe()
    }
  }, [realtime])
 
  const addTodo = () => {
    if (!inputText.trim()) return
 
    const todo: Todo = {
      id: `todo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
      text: inputText,
      done: false,
      createdBy: myId.current,
      createdAt: Date.now(),
    }
 
    // Optimistic update
    setTodos(prev => [...prev, todo])
    setInputText('')
 
    // Broadcast to peers (userId is automatically attached by the SDK)
    channelRef.current?.publish('todo:created', todo)
  }
 
  const toggleTodo = (id: string) => {
    setTodos(prev => prev.map(t => {
      if (t.id !== id) return t
      const updated = { ...t, done: !t.done }
      channelRef.current?.publish('todo:toggled', { id, done: updated.done })
      return updated
    }))
  }
 
  const deleteTodo = (id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id))
    channelRef.current?.publish('todo:deleted', { id })
  }
 
  return (
    <div className="max-w-lg mx-auto p-6">
      <div className="flex items-center gap-2 mb-6 text-sm text-gray-400">
        <span className="w-2 h-2 rounded-full bg-green-400" />
        Synced in real time
      </div>
 
      <form
        onSubmit={e => { e.preventDefault(); addTodo() }}
        className="flex gap-2 mb-6"
      >
        <input
          value={inputText}
          onChange={e => setInputText(e.target.value)}
          placeholder="Add a todo..."
          className="flex-1 bg-gray-900 border border-gray-700 rounded-xl px-4 py-2 text-white"
        />
        <button
          type="submit"
          className="bg-blue-600 text-white px-6 py-2 rounded-xl font-medium"
        >
          Add
        </button>
      </form>
 
      <div className="space-y-2">
        {todos.length === 0 && (
          <p className="text-gray-500 text-center py-8">
            No todos yet. Add one above, then open another tab to see real-time sync!
          </p>
        )}
        {todos.map(todo => (
          <div
            key={todo.id}
            className="flex items-center gap-3 p-3 rounded-xl border border-gray-800 bg-gray-900/50 group"
          >
            <button
              onClick={() => toggleTodo(todo.id)}
              className={`w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 ${
                todo.done
                  ? 'bg-green-500 border-green-500'
                  : 'border-gray-600 hover:border-green-500'
              }`}
            >
              {todo.done && <span className="text-white text-xs">✓</span>}
            </button>
            <span className={`flex-1 text-sm ${todo.done ? 'line-through text-gray-500' : 'text-white'}`}>
              {todo.text}
            </span>
            <button
              onClick={() => deleteTodo(todo.id)}
              className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 text-xs transition-opacity"
            >
              Delete
            </button>
          </div>
        ))}
      </div>
 
      {todos.length > 0 && (
        <div className="mt-4 text-xs text-gray-600 text-center">
          {todos.filter(t => t.done).length} of {todos.length} completed
        </div>
      )}
    </div>
  )
}

Try it

Open http://localhost:5173 in two browser windows. Add a todo in one — watch it appear instantly in the other.

This example has no persistence — todos are lost on page refresh. For persistence, add { persist: true } to publish() and load history on mount with channel.getHistory().