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:
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.
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
Property Type Description 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
Property Type Description 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
Property Type Description 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
Property Type Description 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
Option Type Description 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
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 > 100 n ? latestSeq - 100 n : 0 n ;
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
Property Type Description 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
Option Type Description 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
Property Type Description 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
Option Type Description 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
Option Type Description 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.
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
Option Type Default Description autoUnregisterbooleantrueUnregister resolvers on unmount
Return Value
Property Type Description 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
Option Type Default Description mapNamestring- Filter rejections by map name maxHistorynumber100Max rejections to keep in history
Return Value
Property Type Description 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 ;
}
Edit this page on GitHub
© 2025 TopGun Inc. Documentation licensed under CC BY 4.0.