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 stateNo 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().