Build a Real-time Todo App with TopGun
In this tutorial, you will build a React todo application that works offline and can sync in real time across browser tabs. You will learn how to use TopGun’s core React hooks: useQuery, useMutation, and TopGunProvider, with the IDBAdapter for offline persistence.
Time: ~30 minutes | Difficulty: Beginner
What you will build:
- A todo list with add, toggle, and delete functionality
- Offline-first data that persists across page refreshes
- Optional real-time sync between multiple browser tabs
Prerequisites
Before starting, make sure you have:
- Node.js 18+ and pnpm (or npm/yarn) installed
- (Optional) The TopGun server, if you want to test real-time sync between browsers. See Quickstart for setup.
Step 1: Create the Project
Scaffold a new Vite + React + TypeScript project:
pnpm create vite todo-app --template react-ts
cd todo-app
Install TopGun packages:
pnpm add @topgunbuild/client @topgunbuild/adapters @topgunbuild/react
Step 2: Define the Todo Type
Create a file for your data types. This interface describes the shape of a todo item stored in TopGun.
Create src/types.ts:
export interface TodoItem {
text: string;
done: boolean;
}
Each todo has display text and a done boolean for the completed state. TopGun automatically exposes a _key string on every query result item — you use that for React keys and mutation calls instead of a separate id field.
Step 3: Initialize the TopGun Client
Create src/App.tsx with the client at module level so it is shared across the component tree:
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';
import { AddTodo } from './AddTodo';
import { TodoList } from './TodoList';
export const client = new TopGunClient({
storage: new IDBAdapter(),
// Uncomment to connect to a server:
// serverUrl: 'ws://localhost:8080',
});
function App() {
return (
<div
style={{
maxWidth: '500px',
margin: '40px auto',
padding: '0 16px',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h1 style={{ fontSize: '24px', marginBottom: '24px' }}>
TopGun Todo App
</h1>
<AddTodo />
<TodoList />
<footer
style={{
marginTop: '32px',
paddingTop: '16px',
borderTop: '1px solid #eee',
color: '#999',
fontSize: '13px',
}}
>
Data persists in IndexedDB. Open in two tabs when sync is enabled!
</footer>
</div>
);
}
export default App;
Now replace the contents of src/main.tsx:
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { TopGunProvider } from '@topgunbuild/react';
import App, { client } from './App';
import './index.css';
// Start the client so IndexedDB initializes and queued writes can persist.
// Non-blocking — UI renders immediately, persistence drains in the background.
client.start();
ReactDOM.createRoot(document.getElementById('root')!).render(
<StrictMode>
<TopGunProvider client={client}>
<App />
</TopGunProvider>
</StrictMode>,
);
Key points:
IDBAdapterpersists data to IndexedDB so todos survive page refreshesTopGunProvidermakes the client available to all React hooksclient.start()is non-blocking — the UI renders immediately, IndexedDB initializes lazily and queued writes drain in the background
Step 4: Build the AddTodo Component
Create src/AddTodo.tsx:
import { useState } from 'react';
import { useMutation } from '@topgunbuild/react';
import type { TodoItem } from './types';
export function AddTodo() {
const { create } = useMutation<TodoItem>('todos');
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
// Write the todo locally — this is instant, no network wait
create(`todo-${Date.now()}`, {
text: trimmed,
done: false,
});
setText('');
};
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
style={{
flex: 1,
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '16px',
}}
/>
<button
type="submit"
style={{
padding: '8px 16px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
}}
>
Add
</button>
</form>
);
}
The useMutation<TodoItem>('todos') hook provides create, update, and remove functions for the todos map. Writing via create() is synchronous and local-first — the todo appears in the UI instantly.
Step 5: Build the TodoItem Component
Create src/TodoItem.tsx:
import { useMutation } from '@topgunbuild/react';
import type { TodoItem } from './types';
import type { QueryResultItem } from '@topgunbuild/react';
interface TodoItemProps {
item: QueryResultItem<TodoItem>;
}
export function TodoItem({ item }: TodoItemProps) {
const { update, remove } = useMutation<TodoItem>('todos');
const toggleDone = () => {
update(item._key, {
...item,
done: !item.done,
});
};
const deleteTodo = () => {
remove(item._key);
};
return (
<li
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 0',
borderBottom: '1px solid #eee',
}}
>
<input
type="checkbox"
checked={item.done}
onChange={toggleDone}
style={{ width: '18px', height: '18px' }}
/>
<span
style={{
flex: 1,
textDecoration: item.done ? 'line-through' : 'none',
color: item.done ? '#999' : '#333',
fontSize: '16px',
}}
>
{item.text}
</span>
<button
onClick={deleteTodo}
style={{
padding: '4px 8px',
backgroundColor: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
>
Delete
</button>
</li>
);
}
Both toggleDone and deleteTodo use useMutation to operate on the local data. Changes propagate to other tabs automatically.
Step 6: Build the TodoList Component
Create src/TodoList.tsx:
import { useQuery } from '@topgunbuild/react';
import { TodoItem as TodoItemCard } from './TodoItem';
import type { TodoItem } from './types';
export function TodoList() {
// Subscribe to the 'todos' map — re-renders automatically on changes
const { data: todos = [] } = useQuery<TodoItem>('todos');
if (todos.length === 0) {
return (
<p style={{ color: '#999', textAlign: 'center', padding: '24px 0' }}>
No todos yet. Add one above!
</p>
);
}
return (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{todos.map((item) => (
<TodoItemCard key={item._key} item={item} />
))}
</ul>
);
}
useQuery<TodoItem>('todos') returns a flat array that re-renders whenever:
- You add, edit, or delete a todo locally
- Another browser tab modifies a todo (when sync is enabled)
- The server sends updates from other clients (when connected)
Step 7: Wire Up the App Component
The App.tsx file from Step 3 already imports AddTodo and TodoList. Verify your src/App.tsx imports from ./AddTodo and ./TodoList. Your project structure should look like:
src/
App.tsx ← client init + app shell
main.tsx ← TopGunProvider + StrictMode
AddTodo.tsx ← create hook
TodoItem.tsx ← update + remove hooks
TodoList.tsx ← useQuery hook
types.ts ← TodoItem interface
index.css
Step 8: Run the App
Start the development server:
pnpm dev
Open http://localhost:5173 in your browser. You should see the todo app. Try adding a few todos, toggling them complete, and deleting them.
Step 9: Test Offline Capability
TopGun is offline-first, so the app persists data across reloads using IndexedDB — no server needed:
- Open the app in your browser
- Add some todos — they appear instantly and persist in IndexedDB
- Refresh the page — your todos are still there (loaded from IndexedDB)
- Open DevTools (F12) → Network → set Throttling to Offline
- Add more todos while offline — they still appear and are stored locally
- Refresh again — offline todos persist because they live in IndexedDB, not the network
All writes are stored locally first. When connectivity is restored (and a server is configured), they sync automatically.
Step 10: Add real-time sync (optional)
To test real-time sync between browser tabs or devices:
- Uncomment the
serverUrlline insrc/App.tsx:
export const client = new TopGunClient({
storage: new IDBAdapter(),
serverUrl: 'ws://localhost:8080',
});
- Start the TopGun server in a second terminal:
pnpm start:server
- Open
http://localhost:5173in two browser tabs. - Add a todo in Tab 1 — it appears in Tab 2 within milliseconds.
- Toggle a todo in Tab 2 — the checkbox updates in Tab 1.
- Delete a todo in either tab — it disappears from both.
This works because both tabs share the same TopGun client connection and receive real-time updates through the WebSocket connection to the server.
How It Works
Here is what happens under the hood when you add a todo:
useMutation.create(key, value)writes to the local data store in memory- The write is stamped with a Hybrid Logical Clock (HLC) timestamp for causal ordering
- The
IDBAdapterpersists the write to IndexedDB (async, non-blocking) - The
SyncEnginebatches the write and sends it to the server via WebSocket (when connected) - The server merges the write using HLC timestamps and broadcasts to other subscribers
- Other clients receive the update and merge it into their local state
- React components subscribed via
useQueryre-render with the new data
If the network is unavailable at step 4, the write is queued. When connectivity is restored, Merkle Tree delta sync efficiently determines what needs to be sent without retransmitting everything.
Next Steps
Now that you have a working todo app, explore more TopGun features:
- Live Queries — Subscribe to filtered subsets of data with field projection
- Pub/Sub Topics — Broadcast ephemeral messages (typing indicators, presence)
- Authentication — Add JWT-based authentication to your app
- Schema & Type Safety — Add compile-time type checking with
TopGunClient<TSchema> - AI Builder Guide — Build a TopGun app with Claude Code, Cursor, or Codex
- Concepts: CRDTs & Time — Understand how conflict resolution works