React
Requires react (optional peer dependency — see Installation). Import from @ethereum-radio/indexer/react.
useCursor
import {useCursor} from '@ethereum-radio/indexer/react';
function useCursor(args: UseCursorArgs): UseCursorResult;
A thin binding over the same Cursor engine createRadio uses — plain useState/useEffect/useCallback, no @tanstack/react-query or other caching layer. A consumer wanting request de-duplication across multiple useCursor call sites pointed at the same key should reach for a real query-cache library on top of this.
UseCursorArgs
interface UseCursorArgs {
provider: LogsProvider;
store: SpanStore;
key: string;
address: string | string[];
topics?: (string | string[] | null)[];
floorBlock: bigint;
blockRangeLimit: bigint;
/** Optional forward-scan anchor — enables fetchForward() extending from a known block. */
atBlock?: bigint;
/** If set, re-runs the tip-tailing sync on this interval (ms). Default: no polling. */
pollIntervalMs?: number;
/** Required for checkForReorg() — see CursorConfig. */
checkpointStore?: Store<Checkpoint[]>;
}
Same shape as CursorConfig, plus pollIntervalMs.
UseCursorResult
interface UseCursorResult {
scannedSpans: Span[];
cellStates: Cell[];
isFullyScanned: boolean;
isLoading: boolean;
error: Error | undefined;
tip: bigint | undefined;
scanRange: (fromBlock: bigint, toBlock: bigint) => Promise<RawLog[]>;
fetchHistory: () => Promise<RawLog[]>;
fetchForward: () => Promise<RawLog[]>;
checkForReorg: (fromBlock: bigint, toBlock: bigint) => Promise<ReorgCheckResult | undefined>;
refresh: () => Promise<void>;
}
On mount (and whenever the cursor's identity changes — a different key/address/topics), the hook runs an initial tip-tailing sync() automatically. If pollIntervalMs is set, it repeats that sync on an interval — a simple, adapter-agnostic substitute for a live push subscription, since a real push subscription would need a watch-capable provider (which viem and ethers support differently), out of scope for this package's framework-agnostic core.
scanRange/fetchHistory/fetchForward are the same operations as on a Cursor, wired to update scannedSpans/isLoading/error as they run. This hook is for driving the cursor imperatively from component code (a "load more history" button, a scan-progress grid from cellStates) — reach for useRadioController below if you want createRadio's push-style stream inside a component instead, with pause/resume/cancel built in.
checkForReorg is the same operation as Cursor.checkForReorg — wired the same way, resolving to undefined (with error set) rather than throwing if checkpointStore wasn't provided or the provider lacks getBlockHash. This is the one to wire a scan-map grid's "check for reorgs" button to.
useRadioController
import {useRadioController} from '@ethereum-radio/indexer/react';
function useRadioController(args: UseRadioControllerArgs): UseRadioControllerResult;
A React binding over radio()'s streaming replay, adding the controls a long-running scan against a dense contract actually needs: pause and resume without losing progress, cancel, and an "is this stuck?" status — the pattern behind the Try It page's scan-status popover. It starts scanning automatically on mount, same as useCursor.
Pause and resume need no special support from radio() itself. Cursor is already designed to be fully re-derivable from store — every method re-reads store.load(key) fresh rather than trusting in-memory state — so "pause" is just aborting the current radio() call, and "resume" is starting a fresh one against the same store/checkpointStore you passed in, which naturally continues wherever the aborted one left off. This is also why useRadioController, like useCursor, never constructs its own storage: pass stable store/checkpointStore instances (e.g. from a useMemo) if you want pause/resume to actually preserve progress.
UseRadioControllerArgs
interface UseRadioControllerArgs extends CursorConfig {
/** Delay between live-tail sync() polls once history is fully replayed. Default: 4000ms. */
pollIntervalMs?: number;
/** See RadioOptions.yieldEveryMs. Default: 50ms. */
yieldEveryMs?: number;
/** No new chunk, and not yet caught up, for this long -> isTakingAWhile flips true. Default: 15,000ms. */
takingAWhileMs?: number;
/** Caps how many RawLog entries `logs` holds — totalFound still counts everything found. Default: 200. */
logCap?: number;
/** How often status/isTakingAWhile/caught-up are recomputed. Default: 1000ms. */
statusPollMs?: number;
}
Same shape as CursorConfig (so safetyPadding works here too), plus the streaming/status options above.
UseRadioControllerResult
type RadioControllerStatus =
| 'idle' | 'scanning' | 'paused' | 'caught-up' | 'cancelled' | 'error';
interface UseRadioControllerResult {
status: RadioControllerStatus;
logs: RawLog[];
totalFound: number;
isTakingAWhile: boolean;
elapsedMs: number;
error: Error | undefined;
pause: () => void;
resume: () => void;
cancel: () => void;
/** The underlying Cursor — for getCellStates()/checkForReorg()/isFullyScanned(); this hook doesn't duplicate that surface. */
cursor: Cursor;
}
logs/totalFound—logsholds at mostlogCapentries (newest first);totalFoundkeeps counting everything found even past that cap. Chunks buffer internally and flush to state at most once perstatusPollMs— a scan yielding much faster than that doesn't trigger a re-render per chunk.status—'paused'/'cancelled'only ever come from callingpause()/cancel(); everything else reflects the underlying scan.cancel()is terminal in this version —resume()after it is a no-op.isTakingAWhile— true oncetakingAWhileMshas passed with no new chunk and the scan isn't caught up yet. Purely informational (this hook doesn't act on it) — pair it with a "this is taking a while, consider pausing" message and your own Pause button.cursor— the sameCursorthis hook streams from, for anything outside the stream/control surface:getCellStates()for a scan-progress grid,checkForReorg(),isFullyScanned(),scanRange()for manual control.