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:

  1. Node.js 18+ and pnpm (or npm/yarn) installed
  2. (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:

  • IDBAdapter persists data to IndexedDB so todos survive page refreshes
  • TopGunProvider makes the client available to all React hooks
  • client.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:

  1. Open the app in your browser
  2. Add some todos — they appear instantly and persist in IndexedDB
  3. Refresh the page — your todos are still there (loaded from IndexedDB)
  4. Open DevTools (F12) → Network → set Throttling to Offline
  5. Add more todos while offline — they still appear and are stored locally
  6. 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:

  1. Uncomment the serverUrl line in src/App.tsx:
export const client = new TopGunClient({
  storage: new IDBAdapter(),
  serverUrl: 'ws://localhost:8080',
});
  1. Start the TopGun server in a second terminal:
pnpm start:server
  1. Open http://localhost:5173 in two browser tabs.
  2. Add a todo in Tab 1 — it appears in Tab 2 within milliseconds.
  3. Toggle a todo in Tab 2 — the checkbox updates in Tab 1.
  4. 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:

  1. useMutation.create(key, value) writes to the local data store in memory
  2. The write is stamped with a Hybrid Logical Clock (HLC) timestamp for causal ordering
  3. The IDBAdapter persists the write to IndexedDB (async, non-blocking)
  4. The SyncEngine batches the write and sends it to the server via WebSocket (when connected)
  5. The server merges the write using HLC timestamps and broadcasts to other subscribers
  6. Other clients receive the update and merge it into their local state
  7. React components subscribed via useQuery re-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: