DocsReferenceReact Hooks

React Hooks

The @topgunbuild/react package provides a set of hooks to make it easy to build reactive applications with TopGun.

Setup

Wrap your application in the TopGunProvider:
src/App.tsx
import { TopGunClient } from '@topgunbuild/client';
import { TopGunProvider } from '@topgunbuild/react';

const client = new TopGunClient({ ... });

function App() {
  return (
    <TopGunProvider client={client}>
      <YourComponents />
    </TopGunProvider>
  );
}

useQuery

Subscribes to a live query and returns the results. The component will automatically re-render when the data changes.
components/TodoList.tsx
import { useQuery } from '@topgunbuild/react';
import { useState } from 'react';

function TodoList() {
  const [cursor, setCursor] = useState<string | undefined>();

  // QueryFilter options: where, predicate, sort, limit, cursor
  const { data, loading, error, nextCursor, hasMore } = useQuery('todos', {
    where: { completed: false },
    sort: { createdAt: 'desc' },
    limit: 10,
    cursor  // Pass cursor for pagination
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <ul>
        {data.map(todo => <li key={todo._key}>{todo.title}</li>)}
      </ul>
      {hasMore && (
        <button onClick={() => setCursor(nextCursor)}>
          Load More
        </button>
      )}
    </div>
  );
}

Change Tracking

The useQuery hook provides built-in change tracking to help you understand what changed, not just the new state. This enables:

  • Efficient React re-renders (only changed items)
  • Add/remove animations (framer-motion, react-spring)
  • Optimistic UI rollback on specific keys
  • “New item added” notifications

Return Values

PropertyTypeDescription
dataT[]Current query results
loadingbooleanLoading state
errorError | nullError state
lastChangeChangeEvent<T> | nullMost recent change
changesChangeEvent<T>[]All changes since last clearChanges()
clearChanges() => voidClear accumulated changes
nextCursorstring | undefinedCursor for fetching the next page
hasMorebooleanWhether more results are available
cursorStatus'valid' | 'expired' | 'invalid' | 'none'Status of cursor processing (for debugging)

ChangeEvent Structure

interface ChangeEvent<T> {
  type: 'add' | 'update' | 'remove';
  key: string;
  value?: T;           // New value (for add/update)
  previousValue?: T;   // Previous value (for update/remove)
  timestamp: number;   // When the change occurred
}

Using lastChange and changes

components/TodoListWithNotifications.tsx
import { useQuery } from '@topgunbuild/react';
import { useEffect } from 'react';
import { toast } from 'your-toast-library';

function TodoListWithNotifications() {
  const { data, lastChange, changes, clearChanges } = useQuery('todos');

  // Show toast for new items
  useEffect(() => {
    if (lastChange?.type === 'add') {
      toast.success(`New todo: ${lastChange.value.title}`);
    }
  }, [lastChange]);

  // Process all accumulated changes
  useEffect(() => {
    if (changes.length > 0) {
      console.log('Changes since last render:', changes);
      clearChanges(); // Clear after processing
    }
  }, [changes, clearChanges]);

  return <ul>{data.map(todo => <li key={todo._key}>{todo.title}</li>)}</ul>;
}

Callback Options

For a more declarative approach, use the callback options:

components/TodoListWithCallbacks.tsx
import { useQuery } from '@topgunbuild/react';

function TodoListWithCallbacks() {
  const { data } = useQuery('todos', {}, {
    onAdd: (key, todo) => {
      showNotification(`Added: ${todo.title}`);
    },
    onUpdate: (key, todo, previous) => {
      console.log(`Updated ${key}: ${previous.title}${todo.title}`);
    },
    onRemove: (key, previous) => {
      showNotification(`Removed: ${previous.title}`);
    },
    onChange: (change) => {
      // Called for all change types
      analytics.track('data_change', { type: change.type, key: change.key });
    },
    maxChanges: 100 // Limit accumulated changes (default: 1000)
  });

  return <ul>{data.map(todo => <li key={todo._key}>{todo.title}</li>)}</ul>;
}

Animation Integration

The change tracking works seamlessly with animation libraries like framer-motion:

components/AnimatedTodoList.tsx
import { useQuery } from '@topgunbuild/react';
import { AnimatePresence, motion } from 'framer-motion';

function AnimatedTodoList() {
  const { data } = useQuery('todos');

  return (
    <AnimatePresence>
      {data.map(todo => (
        <motion.li
          key={todo._key}
          initial={{ opacity: 0, x: -20 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: 20 }}
        >
          {todo.title}
        </motion.li>
      ))}
    </AnimatePresence>
  );
}

useMap / useORMap

Get direct access to a Map CRDT instance. The component will re-render whenever the map changes.

Use useMap for Last-Write-Wins maps (simple key-value) and useORMap for Observed-Remove maps (multi-value/sets).

components/UserProfile.tsx
import { useMap, useORMap } from '@topgunbuild/react';

// Simple Key-Value Store
function UserProfile({ userId }) {
  const map = useMap('users');
  const user = map.get(userId);

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={() => map.set(userId, { ...user, active: true })}>
        Activate
      </button>
    </div>
  );
}

// Multi-Value Set (Tags, Categories)
function ProductTags({ productId }) {
  const tagsMap = useORMap('product_tags');
  const tags = tagsMap.get(productId) || []; // Returns array or empty

  return (
    <div>
      {tags.map(tag => (
        <span key={tag} onClick={() => tagsMap.remove(productId, tag)}>
          {tag}
        </span>
      ))}
      <button onClick={() => tagsMap.add(productId, 'new-tag')}>
        Add Tag
      </button>
    </div>
  );
}

useMutation

A helper hook for performing mutations on a map without subscribing to changes.
components/CreateTodo.tsx
import { useMutation } from '@topgunbuild/react';

function CreateTodo() {
  // Returns { create, update, remove, map }
  const { create, update, remove } = useMutation('todos');

  const handleCreate = (text) => {
    const id = crypto.randomUUID();
    create(id, { text, completed: false, createdAt: Date.now() });
  };

  const handleComplete = (id, todo) => {
    update(id, { ...todo, completed: true });
  };

  const handleDelete = (id) => {
    remove(id);
  };

  return <button onClick={() => handleCreate('New Task')}>Add</button>;
}

usePNCounter

Subscribe to a distributed counter that supports increment/decrement with offline support.
components/LikeButton.tsx
import { usePNCounter } from '@topgunbuild/react';

function LikeButton({ postId }) {
const { value, increment, decrement, add, loading } = usePNCounter(`likes:${postId}`);

return (
  <div className="flex items-center gap-2">
    <button onClick={decrement} disabled={loading}>-</button>
    <span>{value}</span>
    <button onClick={increment} disabled={loading}>+</button>
  </div>
);
}

Return Values

PropertyTypeDescription
valuenumberCurrent counter value
loadingbooleanTrue while initial state is loading
increment() => voidIncrement by 1
decrement() => voidDecrement by 1
add(delta: number) => voidAdd arbitrary amount (positive or negative)

Offline Support: Counter operations work offline and are persisted to IndexedDB. Changes sync automatically when the connection is restored. See the PN-Counter Guide for more details.

useEntryProcessor

Execute atomic read-modify-write operations on the server with loading and error states.
components/LikeButton.tsx
import { useEntryProcessor } from '@topgunbuild/react';
import { BuiltInProcessors } from '@topgunbuild/core';
import { useMemo } from 'react';

function LikeButton({ postId }) {
const processor = useMemo(() => BuiltInProcessors.INCREMENT(1), []);
const { execute, executing, lastResult, error } = useEntryProcessor('likes', processor);

const handleLike = async () => {
  const result = await execute(postId);
  if (result.success) {
    console.log('New count:', result.newValue);
  }
};

return (
  <button onClick={handleLike} disabled={executing}>
    {executing ? '...' : 'Like'}
  </button>
);
}

Return Values

PropertyTypeDescription
execute(key: string, args?: unknown) => Promise<EntryProcessorResult>Execute processor on a key
executeMany(keys: string[], args?: unknown) => Promise<Map<string, EntryProcessorResult>>Execute on multiple keys
executingbooleanTrue while operation is in progress
lastResultEntryProcessorResult | nullMost recent execution result
errorError | nullLast error encountered
reset() => voidClear lastResult and error

Atomic Operations: Entry processors run on the server, solving race conditions in read-modify-write scenarios. See the Entry Processor Guide for more details.

useEventJournal

Subscribe to the Event Journal to receive real-time notifications of all map changes (PUT, UPDATE, DELETE).
components/ActivityFeed.tsx
import { useEventJournal } from '@topgunbuild/react';

function ActivityFeed() {
  const { events, lastEvent, isSubscribed } = useEventJournal({
    mapName: 'orders',        // Filter by map name (optional)
    types: ['PUT', 'UPDATE'], // Filter by event types (optional)
    maxEvents: 50,            // Max events to keep in state (default: 100)
  });

  return (
    <div>
      <h2>Recent Activity {isSubscribed && '🟢'}</h2>
      <ul>
        {events.map((event) => (
          <li key={event.sequence.toString()}>
            [{event.type}] {event.mapName}:{event.key}
          </li>
        ))}
      </ul>
    </div>
  );
}

Return Values

PropertyTypeDescription
eventsJournalEvent[]Array of recent events (newest last)
lastEventJournalEvent | nullMost recently received event
isSubscribedbooleanWhether subscription is active
clearEvents() => voidClear accumulated events
readFrom(sequence: bigint, limit?: number) => Promise<JournalEvent[]>Read historical events
getLatestSequence() => Promise<bigint>Get the latest sequence number

JournalEvent Structure

interface JournalEvent {
  sequence: bigint;           // Monotonically increasing ID
  type: 'PUT' | 'UPDATE' | 'DELETE';
  mapName: string;            // Name of the map that changed
  key: string;                // Key that was modified
  value?: unknown;            // New value (undefined for DELETE)
  previousValue?: unknown;    // Previous value (for UPDATE/DELETE)
  timestamp: Timestamp;       // HLC timestamp
  nodeId: string;             // Node that made the change
  metadata?: Record<string, unknown>;
}

Options

OptionTypeDescription
mapNamestringFilter events by map name
types('PUT' | 'UPDATE' | 'DELETE')[]Filter by event types
fromSequencebigintStart receiving from this sequence
maxEventsnumberMax events to keep in state (default: 100)
onEvent(event: JournalEvent) => voidCallback for each new event
pausedbooleanPause subscription

With Event Callback

components/OrderNotifications.tsx
import { useEventJournal } from '@topgunbuild/react';
import { toast } from 'your-toast-library';

function OrderNotifications() {
  const { events } = useEventJournal({
    mapName: 'orders',
    types: ['PUT'],
    onEvent: (event) => {
      // Called for each new event
      toast.success(`New order: ${event.key}`);
    },
  });

  return <OrderList orders={events} />;
}

Reading Historical Events

components/AuditLog.tsx
import { useEventJournal } from '@topgunbuild/react';
import { useState } from 'react';

function AuditLog() {
  const { events, readFrom, getLatestSequence, clearEvents } = useEventJournal({
    paused: true, // Don't subscribe, just use readFrom
  });

  const [history, setHistory] = useState([]);

  const loadHistory = async () => {
    const latestSeq = await getLatestSequence();
    const startSeq = latestSeq > 100n ? latestSeq - 100n : 0n;
    const historicalEvents = await readFrom(startSeq, 100);
    setHistory(historicalEvents);
  };

  return (
    <div>
      <button onClick={loadHistory}>Load History</button>
      <button onClick={clearEvents}>Clear</button>
      <ul>
        {history.map((e) => (
          <li key={e.sequence.toString()}>
            {new Date(e.timestamp.millis).toISOString()} - {e.type} {e.key}
          </li>
        ))}
      </ul>
    </div>
  );
}

Server-side Feature: The Event Journal must be enabled on the server with eventJournalEnabled: true. Events are persisted to PostgreSQL for durability. See the Event Journal Guide for server configuration.

useSearch

Subscribe to live full-text search results with BM25 relevance ranking. Results update automatically when matching documents change.
components/SearchResults.tsx
import { useSearch } from '@topgunbuild/react';
import { useState } from 'react';

function SearchResults() {
  const [searchTerm, setSearchTerm] = useState('');

  const { results, loading, error } = useSearch<Article>('articles', searchTerm, {
    limit: 20,
    boost: { title: 2.0 }
  });

  if (loading) return <div>Searching...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search articles..."
      />
      <ul>
        {results.map(r => (
          <li key={r.key}>
            [{r.score.toFixed(2)}] {r.value.title}
            <small>Matched: {r.matchedTerms.join(', ')}</small>
          </li>
        ))}
      </ul>
    </div>
  );
}

Return Values

PropertyTypeDescription
resultsSearchResult<T>[]Current search results sorted by relevance score
loadingbooleanTrue while waiting for initial results
errorError | nullError if search subscription failed

SearchResult Structure

interface SearchResult<T> {
  key: string;        // Document key
  value: T;           // Full document value
  score: number;      // BM25 relevance score
  matchedTerms: string[];  // Stemmed terms that matched
}

Options

OptionTypeDescription
limitnumberMaximum results to return
minScorenumberMinimum BM25 score threshold
boostRecord<string, number>Field boost weights (e.g., { title: 2.0 })
debounceMsnumberDebounce delay for query changes

With Debounce

For search-as-you-type interfaces, use debounceMs to avoid excessive server requests:

components/ProductSearch.tsx
import { useSearch } from '@topgunbuild/react';
import { useState } from 'react';

function ProductSearch() {
  const [input, setInput] = useState('');

  // Debounce by 300ms for search-as-you-type
  const { results, loading } = useSearch<Product>('products', input, {
    debounceMs: 300,
    limit: 10,
    minScore: 0.5
  });

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Search products..."
      />
      {loading && <span>Searching...</span>}
      <ul>
        {results.map(r => (
          <li key={r.key}>{r.value.name} - ${r.value.price}</li>
        ))}
      </ul>
    </div>
  );
}

Server-side Feature: Full-text search must be enabled on the server for the target map with fullTextSearch configuration. See the Full-Text Search Guide for server setup.

useHybridQuery

Combine full-text search with traditional filter predicates in a single query. Ideal for faceted search UIs.
components/TechArticles.tsx
import { useHybridQuery } from '@topgunbuild/react';
import { Predicates } from '@topgunbuild/core';

function TechArticles() {
  // Combine FTS with traditional filters
  const { results, loading, error } = useHybridQuery<Article>('articles', {
    predicate: Predicates.and(
      Predicates.match('body', 'machine learning'),  // FTS predicate
      Predicates.equal('category', 'tech')           // Filter predicate
    ),
    sort: { _score: 'desc' },  // Sort by relevance
    limit: 20
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {results.map(r => (
        <li key={r._key}>
          [{r._score?.toFixed(2)}] {r.value.title}
          <small>Matched: {r._matchedTerms?.join(', ')}</small>
        </li>
      ))}
    </ul>
  );
}

Return Values

PropertyTypeDescription
resultsHybridResultItem<T>[]Current query results with scores
loadingbooleanTrue while waiting for initial results
errorError | nullError if query failed

HybridResultItem Structure

interface HybridResultItem<T> {
  value: T;                     // Document value
  _key: string;                 // Document key
  _score?: number;              // BM25 relevance score (for FTS queries)
  _matchedTerms?: string[];     // Stemmed terms that matched
}

Filter Options

OptionTypeDescription
predicatePredicateNodePredicate tree (FTS + filters)
whereRecord<string, any>Simple equality filters
sortRecord<string, 'asc' | 'desc'>Sort fields (use _score for relevance)
limitnumberMaximum results per page
cursorstringOpaque cursor for pagination (from nextCursor)

Hook Options

OptionTypeDescription
skipbooleanSkip query execution (for conditional queries)

Dynamic Filters

Build complex faceted search UIs with dynamic predicates:

components/FacetedSearch.tsx
import { useHybridQuery } from '@topgunbuild/react';
import { Predicates } from '@topgunbuild/core';
import { useState, useMemo } from 'react';

function FacetedSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const [category, setCategory] = useState('all');

  const filter = useMemo(() => {
    const conditions = [];

    if (searchTerm.trim()) {
      conditions.push(Predicates.match('description', searchTerm));
    }
    if (category !== 'all') {
      conditions.push(Predicates.equal('category', category));
    }

    return {
      predicate: conditions.length > 1
        ? Predicates.and(...conditions)
        : conditions[0],
      sort: searchTerm ? { _score: 'desc' } : { createdAt: 'desc' },
      limit: 20
    };
  }, [searchTerm, category]);

  const { results, loading } = useHybridQuery<Product>('products', filter);

  return (
    <div>
      <input value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="electronics">Electronics</option>
      </select>
      {loading && <span>Loading...</span>}
      <ul>
        {results.map(r => (
          <li key={r._key}>
            {r.value.name}
            {r._score && <span> (score: {r._score.toFixed(2)})</span>}
          </li>
        ))}
      </ul>
    </div>
  );
}

When to use Hybrid vs Search: Use useSearch for pure text search (search box). Use useHybridQuery when you need to combine text search with filters (faceted search, filtered listings). See the Hybrid Queries Guide for more examples.

useTopic

Subscribe to a Pub/Sub topic for ephemeral messaging. Messages are not persisted.
components/ChatRoom.tsx
import { useTopic } from '@topgunbuild/react';

function ChatRoom({ roomId }) {
  const topic = useTopic(`room:${roomId}`, (msg, ctx) => {
    console.log('New message:', msg);
  });

  const sendMessage = (text) => {
    topic.publish({ text, sender: 'me' });
  };

  return <button onClick={() => sendMessage('Hello!')}>Send</button>;
}

useClient

Get direct access to the TopGunClient instance from context.
components/CustomComponent.tsx
import { useClient } from '@topgunbuild/react';

function CustomComponent() {
const client = useClient();

// Direct access to client methods
const lock = client.getLock('my-resource');

return <div>...</div>;
}

useConflictResolver

Manage conflict resolvers for a specific map. Auto-unregisters on unmount.
components/BookingManager.tsx
import { useConflictResolver } from '@topgunbuild/react';
import { useEffect } from 'react';

function BookingManager() {
const { register, unregister, list, loading, error, registered } =
  useConflictResolver('bookings');

useEffect(() => {
  // Register first-write-wins resolver on mount
  register({
    name: 'first-write-wins',
    code: `
      if (context.localValue !== undefined) {
        return { action: 'reject', reason: 'Slot already booked' };
      }
      return { action: 'accept', value: context.remoteValue };
    `,
    priority: 100,
  });
}, []);

return (
  <div>
    {loading && <span>Registering...</span>}
    {error && <span>Error: {error.message}</span>}
    <p>Registered resolvers: {registered.join(', ')}</p>
  </div>
);
}

Options

OptionTypeDefaultDescription
autoUnregisterbooleantrueUnregister resolvers on unmount

Return Value

PropertyTypeDescription
register(resolver) => Promise<RegisterResult>Register a resolver
unregister(name) => Promise<RegisterResult>Unregister by name
list() => Promise<ResolverInfo[]>List registered resolvers
loadingbooleanOperation in progress
errorError | nullLast error
registeredstring[]Names of resolvers registered by this hook

useMergeRejections

Subscribe to merge rejection events from conflict resolvers.
components/BookingForm.tsx
import { useMergeRejections } from '@topgunbuild/react';
import { useEffect } from 'react';
import { toast } from 'your-toast-library';

function BookingForm() {
const { rejections, lastRejection, clear } = useMergeRejections({
  mapName: 'bookings',
  maxHistory: 50,
});

// Show toast for rejections
useEffect(() => {
  if (lastRejection) {
    toast.error(`Booking failed: ${lastRejection.reason}`);
    clear();
  }
}, [lastRejection]);

return (
  <div>
    {/* Form UI */}
    {rejections.length > 0 && (
      <div className="error-list">
        {rejections.map((r, i) => (
          <p key={i}>{r.key}: {r.reason}</p>
        ))}
      </div>
    )}
  </div>
);
}

Options

OptionTypeDefaultDescription
mapNamestring-Filter rejections by map name
maxHistorynumber100Max rejections to keep in history

Return Value

PropertyTypeDescription
rejectionsMergeRejection[]List of recent rejections
lastRejectionMergeRejection | nullMost recent rejection
clear() => voidClear rejection history

MergeRejection Type

interface MergeRejection {
  mapName: string;
  key: string;
  attemptedValue: unknown;  // null for deletions
  reason: string;
  timestamp: Timestamp;
  nodeId: string;
}

See Also: The Conflict Resolvers Guide for detailed examples and use cases.