React Hooks
Complete reference for Rising Tide SDK React hooks.
The data hooks on this page read the SDK client from RisingTideProvider (also exported as RisingTideQueryProvider) and must be mounted below it, and below a QueryClientProvider. RisingTideProviders does not mount that context - see Query Provider. The hooks under Money and wallet, Cross-app launch and The next rung take what they need as arguments and need only a QueryClientProvider.
Smart product hooks
// List smart products
useSmartProducts(params?: SmartProductListParams)
// Get one by numeric id; pass `include` to eager-load relations
useSmartProduct(
id: number | undefined,
include?: Array<'bracketDetails' | 'productType' | 'instances' | 'contributions' | 'appListings'>
)
// List ProductContribution rows for a product
useSmartProductContributions(productId: number | undefined)Product instance hooks
// List instances for a product
useProductInstances(productId: number | undefined, params?: ProductInstanceListParams)
// Get one instance by (productId, instanceId)
useProductInstance(productId: number | undefined, instanceId: number | undefined)
// Cross-domain list of instances owned by a Stacks address
useUserInstances(stxAddress: string | undefined, params?: UserInstancesParams)useUserInstances returns editions, reservations, bracket entries and matches in a single response, joined with parent SmartProduct info. Filter to a single domain via params.domain (PAGE | EVENT | PREDICTION | GAME).
Product type hooks
useProductTypes(params?: ProductTypeListParams)
useProductType(slug: string | undefined)Style set hooks
useStyleSets(params?: StyleListParams)
useStyleSet(id: number | undefined)Funding group hooks
useFundingGroups(params?: FundingGroupListParams)
useFundingGroup(id: number | undefined)User hooks
// A user by address, with the SDK client passed in; returns { user, loading, error, refetch }
useUserByAddress(stxAddress: string, sdk: RisingTideSDK)The current authenticated user comes from useProtocolUser, which ships from
@rising-tide/ui/auth rather than this entry point - it reads the context
ProtocolUserProvider establishes, so it lives beside that provider.
Aggregate user hooks
Five address-keyed hooks power the cross-app user hub. Each is backed by a server-side cached endpoint:
// Public-facing identity + cross-app activity rollup
useUserProfile(stxAddress: string | undefined)
// Revenue summary + per-app breakdown + daily totals
useUserRevenue(stxAddress: string | undefined, params?: UserRevenueQueryParams)
// Who collected from this user (with productCount aggregation)
useUserCollectors(stxAddress: string | undefined, params?: UserCollectorsQueryParams)
// Creators this user collects from
useUserSupporting(stxAddress: string | undefined, params?: UserSupportingQueryParams)
// Personal feed: recent items from creators this user collects
useUserFeed(stxAddress: string | undefined, params?: UserFeedQueryParams)
// What is trending in a single app right now
useAppTrending(appId: number | undefined, params?: AppTrendingQueryParams)Each endpoint is cached at the API layer (profile + collectors 5min, revenue 2min, feed 3min, trending 15min) so polling these from a UI tab is cheap.
The next rung
// The one next thing the protocol would have the signed-in person do, and the shelf behind it
useNextRung(sdk: RisingTideSDK, opts?: { appId?: number })
// -> { next: NextRung | null, shelf: AchievementShelf | null, loading, refetch }The one source for the next-rung line in every app. It reads users.getAchievements for the
signed-in address once the auth token is on the wire (without the token the route answers the
stranger's view, and a cache primed with that would show a signed-in person nothing), so
next is null only while there is no answer yet. Render next.line where your primary action is;
when next.appId is not your app, make next.appUrl the action. next.graduated is a state with
its own closing line and nothing to open. Pass appId for the app the surface belongs to: a door
that has just opened reads as news everywhere except in the app behind it. achievementShelfKey
is exported so an app can invalidate the line after the act it asked for.
Invite hooks
// Current user's invites
useInvites(params?: InviteListParams)
// Preview a code (unauthenticated). Returns a generic shape on failure.
useInvitePreview(code: string | undefined)Cross-app launch
useAppLaunchTarget(): { target?: "_blank"; rel?: string }Spread the result onto a link that opens another Rising Tide app. Never hardcode a target on one - both answers are wrong somewhere, and which one is right is a property of the browser at the moment of the tap rather than of your source.
const launchTarget = useAppLaunchTarget();
<a href={app.frontendUrl} {...launchTarget}>{app.name}</a>There is no API that answers "is that other app installed". The only readable signal is whether this app is running installed, so that is what the hook reads:
| This app is | The link | Why |
|---|---|---|
| running installed | navigates same-tab | The navigation-capturing algorithm skips auxiliary browsing contexts, so a _blank gets a Custom Tab instead of the app. The platform keeps this window behind the one it opens. |
| in a browser tab | opens a new tab | Nothing to capture, and a same-tab hop costs the launcher that sent you. |
Either way the surface you launched from is still there to come back to.
Two things this does not cover. A link to one specific item inside an app is not a launch - it always opens a new tab. And the resulting behaviour must never be promised in copy: it is user-overridable on Android and desktop Chrome, and absent on iOS, so treat it as progressive enhancement that reads correctly when every tap opens a plain tab.
@rising-tide/conformance enforces this as registry-app-launch-defers-to-platform.
Hook pattern
The read hooks resolve to the SDK's ApiResponse, so the entity is at data.data:
const { data: response, isLoading, error, refetch } = useSmartProduct(productId);
if (isLoading) return <Spinner />;
if (error) return <ErrorState error={error} />;
const product = response?.data;
if (!product) return null;
return <ProductCard product={product} />;Money and wallet
// STX and sBTC balances for an address, read from the Hiro API; network defaults to testnet
useWalletBalance(address: string | null, options?: { network?, sbtcContract?, staleTime?, refetchInterval? })
// -> { stx, sbtc, isLoading, error, refetch } (stx in STX, sbtc in BTC - multiply by 100_000_000 for sats)
// Live token prices from the app's OWN /api/prices route (rt-api has none), for glossing sats with a fiat estimate
useTokenPrices(apiUrl = "/api/prices")
// The protocol's metered testnet taps. Takes the SDK client; the server funds the caller's own wallet
useTestnetFaucet(rtClient: RisingTideSDK, options?: { getPrivyToken?, network?, onFunded? })
// -> { available, stx, sbtc }useWalletBalance is in six apps and useTokenPrices in seven, which makes them the two most
widely used hooks in the package.
A USD figure may gloss a sats figure, never replace it - the sat amount is the one that is true, and a price that failed to load must not silently become the only number on screen.
useTestnetFaucet.available is false on anything but testnet - render nothing when it is false. The tap itself is floor-gated per currency server-side: it answers only when a balance is genuinely
low. The tap surface lives on the hub rather than in each app, so a client app usually reads this
to decide whether to point somebody at the hub, not to render a button.
Paid actions
// Whether an address can cover a paid action, BOTH legs of it: the face amount in its currency,
// and the gas, which is always STX. `amount` is in base units (microSTX or sats); 0 for an action
// that costs only gas. `gasUstx` defaults to a contract call (CALL_GAS_USTX); a deploy passes
// deployGasUstx(contractBytes).
usePaidAction({ address, amount, currency: "STX" | "sBTC", gasUstx?, network? })
// -> { canAfford, isLoading, shortfall: { currency, amount } | null, held: { ustx, sats } }
// One currency, one all-in amount. The primitive under usePaidAction; gas is yours to include.
useAffordable({ address, amount, currency, network? })
// -> { canAfford, balance, shortfall, isLoading }
// The same decisions as pure functions, for anyone gating outside React
affordability({ amount, balance, balanceKnown }) // -> { canAfford, shortfall }
balanceIsKnown({ address, isLoading, error })
legsFor({ amount, currency, gasUstx? }) // -> { ustx, sats }Every control that dispatches a protocol write gates on usePaidAction, or on a hook of your own
that wraps it and is named for the action it guards (the *Affordability suffix is what the rule
reads). rt-conform fails a file that
dispatches a write and never asks, and a file that compares a balance to a cost by hand
(paid-write-consults-the-gate, no-hand-rolled-affordability).
canAfford is false only when a balance is known and short. An in-flight or errored read
leaves the action enabled - the defect this exists to fix is a known zero being ignored, and a
button that flickers disabled on every load trades one bad experience for another. shortfall
names the currency the person is thinking about first: for a sats-priced action short on both
legs it says sats, and the gas leg only when the sats are covered.
A sats-priced action still needs STX: the gas comes out of the STX balance, and a wallet funded only in sats fails on chain with an error that reads as a protocol fault. That is why the hook asks two questions for one action, and why a gate on the face amount alone is a rule violation. The numbers the gate takes are the SDK's - see what a broadcast costs.
Discovery
// Tags, paginated, with `search` for a typeahead
useTags(params?: TagListParams)
// The most-used tags, for a browse surface or an empty search state
usePopularTags(params?: { limit?: number })Onboarding
// Creates the protocol user row after sign-in; takes the SDK client
useCreateUser(sdk: RisingTideSDK)
// -> { createUser(userData), loading, error }The two hooks that complete this flow - the invite code in flight and which path the user signed
in by - ship from @rising-tide/ui/auth and are documented on
Auth, not here.
App surface
// The current theme and the means to change it
useTheme()
// -> { theme: "light" | "dark", isDark, isLight, isLoading, setTheme(theme), toggleTheme() }
// PWA install prompt availability, and the trigger
useInstallPrompt()
// Protocol-wide counters
useProtocolStats()useTheme is in eight apps. Pages render in the viewer's theme, so a client app reads theme
(or isDark) rather than assuming dark. The choice is kept in local storage under rt-theme and
applied to the document as a data-theme attribute; isLoading is true until the stored value has
been read.
useInstallPrompt({ storageKey?, persistentDismissal? }) is what an installable app uses to offer
the install at a moment that makes sense, rather than letting the browser choose one. It returns
{ state, isIos, isDesktop, promptInstall, dismiss, restore, dismissed }. state is unknown,
installed, available (a deferred browser prompt is in hand) or manual (iOS, or a browser
holding its prompt back). There is no API that says whether the app is installed elsewhere - only
whether this window is running installed. isDesktop picks wording: installing on a desktop
makes an app window, not a home-screen icon. dismiss(true) persists only when
persistentDismissal is set, and set it only if a surface calls restore() to undo it.
ThemeToggle is the component that drives useTheme.
Push
import { usePushSubscription } from "@rising-tide/ui/react";
const push = usePushSubscription({ client: rtClient, appId: 10, signedIn: !!user });
// push.state: 'unknown' | 'unsupported' | 'needs-install' | 'unconfigured' | 'denied' | 'off' | 'on'
// push.shouldAsk, push.busy, push.enable(), push.disable(), push.dismissAsk()The browser half of rtClient.push: it asks permission, subscribes this
device for this app, and removes the subscription again. Render the app's own soft ask only while
shouldAsk is true, and call enable() only from a tap on that ask's yes - it is the one thing that
opens the browser's prompt, and a person who blocks that prompt cannot be asked again. "Not now" is
dismissAsk(), which holds for 14 days by default (reaskAfterDays).
needs-install is iOS in a browser tab, where only an app on the Home Screen can receive push.
unconfigured means the server has no key and push is not offered; hide every push control. The hook
reads the key with the state, so a device that would be off on a keyless server is unconfigured
and shouldAsk is false: the ask never opens the browser prompt for a push the server cannot send.
serverOffersPush(client) is that one read, exported for an app that wants it earlier. Call
disable() from sign-out, before the session ends, so a shared device stops receiving the previous
person's notifications.
Upload
// Upload a file, with progress; takes the SDK client
useUploadFile(sdk: RisingTideSDK)
// -> { uploadFile(file, metadata?), loading, progress, error }Wraps rtClient.upload. Identity assets are the exception:
name, bio and avatar are edited at rt-profile, and a client app links out rather than uploading.
Events
// List events; returns the paginated envelope
useEvents(params?: EventListParams)
// One event; `include` eager-loads relations in a single round trip
useEvent(id: number | undefined, include?: string[])
useCreateEvent()
useUpdateEvent() // { id, data }
useDeleteEvent() // idThe mutations invalidate both the list and the row they touched, so a detail view open beside a form refreshes without either knowing about the other.
// Reference data for building an event
useLocations(params?: LocationListParams) // the venue registry
useEventTypes() // the event type catalog
useTicketStyles(params?: StyleSetListParams)// visual templates a ticket renders in
useCurrencyTypes() // what a product may be priced inuseCurrencyTypes is worth reading rather than hardcoding a coin: what a marketplace charges in is
per-marketplace by construction, and the choice belongs to the user.
Cross-app listing
// Which apps this smart product appears in
useListedApps(productId: number | undefined)
// Put it in one or more apps. Idempotent - re-listing is not an error
useListProductOnApps() // { productId, appIds }
// Take it out of one. The product is untouched; only its visibility changes
useRemoveProductListing() // { productId, appId }A product listed in more than one app is the mechanism behind an audience that is not locked inside the app that introduced it. Both mutations invalidate the product keys as well as the listing key, so a detail view open elsewhere does not go stale.
Editions in flight
usePendingAwareEditions(rtClient, productId, { refetchInterval?, enabled? })The read half of send a command; render a query, and the partner to useCommand.
Client transaction flows close on broadcast and never poll the chain, so the moment after somebody
collects there is a transaction nobody can confirm yet. This returns a product's editions as
confirmed truth plus live mempool pending plus the failed registry, every item status
tagged, and polls so a pending item flips to confirmed or failed on its own. The
correlationId from useCommand is the txId here, so the two join without either inventing a
row.
refetchInterval defaults to 8000 ms; pass false for a static render. It takes the client as a
parameter rather than from context, matching useCommand and useDeployProduct.
Commands and deploys
// One way to send a state change
useCommand(rtClient, { getPrivyToken? })
// -> { state, txId, correlationId, reason, errorCode, isSubmitting, submit(input), reset() }
// The deploy sequence: chainhook, draft, sign and broadcast, patch, cleanup on failure
useDeployProduct(rtClient)
// -> { deploy(config, callbacks), reset(), isCreating, step, txId, productId, error, errorCode, requiresManualRecovery }useCommand wraps rtClient.commands.submit. It reads the session's auth method from useProtocolUser, so it must sit inside the auth providers, and it fills in the CommandAuth for you: a Privy session needs getPrivyToken, a connected wallet needs nothing. state is idle, submitting, accepted or rejected, and acceptance means the transaction broadcast - correlationId joins to the txId that usePendingAwareEditions returns. On rejection branch on errorCode (the server's RisingTideError.code) before reason.
useDeployProduct takes a DeployFlowConfig (contractName, ownerAddress, network?, getPrivyToken?, contractCode?, deployFeeSats?, deployFeeCurrency?, deployTimeoutMs?) and DeployFlowCallbacks (createDraft, generateContractCode?, patchDeployTxId, deleteDraft, onAfterDeploy?). step walks registering, creating, deploying, updating, done. Pass deployFeeSats as the fee alone, never the fee plus gas - it is frozen into the contract. requiresManualRecovery is true when the deploy broadcast but the deployTxId patch failed; the chainhook reconciles it. See rtClient.contracts for a complete call.
Polling that sleeps
// An interval that does not run while the tab is hidden or the app is asleep
useVisibleInterval(fn: () => void, ms: number | null, enabled = true)
// The same, outside React. Returns the stop function
setVisibleInterval(fn: () => void, ms: number): () => voidAny timer that reaches the network goes through these, never a bare setInterval: a hidden tab should not hold the database awake, and a phone PWA sleeps. They share React Query's focus state, so a single idle-sleep call pauses them along with every query poll. null for ms disables the interval, and fn may change between renders without resetting the timer.
IdleSleepProvider and useIdleSleep() are that idle-sleep policy. RisingTideProviders mounts the provider for you (testnet only, 10 minutes by default; see its idleSleep config), and TestnetBanner is the surface that tells the person the app is asleep. A sleeping app is a fully working app whose numbers have stopped moving: mutations never pause.
Small utilities
useClipboard({ onSuccess?, onError? }) // -> { copy(text, label?), isCopied, isCopying, error }
useScrollLock(shouldLock: boolean) // locks body scroll while an overlay is open
useTransactionStatus(txId, sdk, options) // imperative chain poller; no app calls ituseTransactionStatus is kept for the record: client flows close on broadcast and do not poll the chain, so prefer rtClient.pendingTransactions.
Stickers
useStickers(params?) // the catalogue
useSticker(id: number | undefined)StickerPicker (a modal grid with search and tag filters) and StickerEditor (drag, scale and rotate inside a style's stickerZone) are the ready-made components; a new sticker starts at defaultPlacement(zone). See Stickers.
A sticker is a single illustrated element that layers onto a page or a ticket, and its artist earns a share of every collect of anything published with it - so a composer's decoration step is a revenue-bearing surface, not chrome.