@qubic.org/react
v1.0.0
Published
React hooks and context providers for the Qubic SDK, powered by [TanStack Query](https://tanstack.com/query).
Readme
@qubic.org/react
React hooks and context providers for the Qubic SDK, powered by TanStack Query.
Installation
bun add @qubic.org/reactPeer dependency: React 18+.
Security model
Seeds never appear in React state or component code:
VaultProviderstores wallets in aRef— invisible to React DevTools, not serialisable, not part of the render cycle.- Components only receive
identities: Identity[](60-char strings). useSendTransfer/useSendContractCallaccess signing internally — the component passes params but never touches a wallet.- Auto-lock on page hide (
visibilitychange) and after a configurable idle timeout.
Session persistence (persistSession prop):
- Seeds re-encrypted with a freshly generated AES-256-GCM key on every unlock.
- The key is stored in IndexedDB as
extractable: false— JS cannot read key bytes even under XSS. - The encrypted payload lives in
sessionStorage(tab-scoped — cleared automatically when the tab closes). - Keys rotate on every restore: the previous IDB key is deleted before wallets are loaded (forward secrecy).
- If decryption fails for any reason the session is silently cleared and the lock form is shown.
Setup
import { createLiveClient, createQueryClient } from '@qubic.org/rpc'
import { importVault } from '@qubic.org/wallet'
import { QubicProvider, VaultProvider } from '@qubic.org/react'
const live = createLiveClient()
const archive = createQueryClient()
const vault = importVault(localStorage.getItem('qubic-vault')!)
export function App() {
return (
<QubicProvider liveClient={live} archiveClient={archive}>
<VaultProvider
vaultData={vault}
persistSession // survives page refresh; expires on tab close
autoLockMs={15 * 60_000} // also auto-locks after 15 min idle
>
<YourApp />
</VaultProvider>
</QubicProvider>
)
}| Prop | Default | Description |
|------|---------|-------------|
| vaultData | required | VaultData from importVault / createVault |
| persistSession | false | Re-encrypt seeds into sessionStorage + IDB on unlock; restore on page refresh |
| sessionStorageKey | 'qubic-session' | Key used in sessionStorage — override for multiple vaults |
| autoLockMs | none | Auto-lock after N ms idle; also locks on visibilitychange |
QubicProvider also acts as a TanStack QueryClientProvider. Pass your own queryClient to share the cache with the rest of your app.
Vault hooks
useVaultState()
Lock/unlock the vault and read the active identities.
import { useVaultState } from '@qubic.org/react'
function WalletPanel() {
const { isUnlocked, identities, isUnlocking, error, unlock, lock } = useVaultState()
if (!isUnlocked) {
return (
<form onSubmit={e => { e.preventDefault(); unlock(e.currentTarget.password.value) }}>
<input name="password" type="password" />
<button disabled={isUnlocking}>Unlock</button>
{error && <p>Wrong password</p>}
</form>
)
}
return (
<div>
{identities.map(id => <p key={id}>{id}</p>)}
<button onClick={lock}>Lock</button>
</div>
)
}Send hooks
useSendTransfer()
Fetches the current tick, builds and signs a QU transfer, broadcasts. Zero wallet exposure.
import { useSendTransfer, useVaultState } from '@qubic.org/react'
function SendPanel() {
const { identities } = useVaultState()
const { mutate: send, isPending, data, error } = useSendTransfer()
return (
<button
disabled={isPending || !identities[0]}
onClick={() => send({
from: identities[0]!,
destination: 'BZBQFLLBNCXEMGLOBHUVFTLUPLVCPQUASSILFABOFFBCADQSSUPNWLZBQEXK',
amount: 1_000_000n,
})}
>
Send
</button>
)
}useSendContractCall()
Signs and broadcasts a smart contract procedure call.
import { useSendContractCall, useVaultState } from '@qubic.org/react'
import { contractIndexToIdentity } from '@qubic.org/crypto'
import { qearn } from '@qubic.org/contracts'
function UnlockPanel() {
const { identities } = useVaultState()
const { mutate: send, isPending } = useSendContractCall()
const call = qearn.buildUnlockInput({ amount: 10_000_000n, lockedEpoch: 212 })
return (
<button
disabled={isPending}
onClick={() => send({
from: identities[0]!,
inputType: call.inputType,
payload: call.payload,
destination: contractIndexToIdentity(qearn.contractIndex),
})}
>
Unlock QU
</button>
)
}Contract query hook
useContractQuery(fn, input)
Wraps any typed contract read function from @qubic.org/contracts as a TanStack Query. identityToPublicKey / publicKeyToIdentity are provided automatically.
import { useContractQuery } from '@qubic.org/react'
import { qearnGetStateOfRound } from '@qubic.org/contracts'
function RoundStats({ epoch }: { epoch: number }) {
const { data, isLoading } = useContractQuery(qearnGetStateOfRound, { epoch })
if (isLoading) return <span>Loading…</span>
return <span>Locked: {data?.totalLockedAmount?.toString()}</span>
}Live API hooks
useTickInfo(refetchIntervalMs?)
const { data } = useTickInfo(3_000)
// data.tick, data.epoch, data.durationuseBalance(identity)
const { data } = useBalance(identities[0])
// data.balanceuseIssuedAssets(identity) / useOwnedAssets(identity) / usePossessedAssets(identity)
const { data: issued } = useIssuedAssets(identity)
const { data: owned } = useOwnedAssets(identity)
const { data: possessed } = usePossessedAssets(identity)Archive API hooks (require archiveClient)
useTransaction(hash)
const { data } = useTransaction(txHash)useEventLogs(request)
const { data } = useEventLogs({
filters: { logType: '0' },
pagination: { offset: 0, size: 20 },
})useTransactionsForIdentity(request)
const { data } = useTransactionsForIdentity({
addressId: identity,
pagination: { offset: 0, size: 20 },
})useQubic()
Direct access to liveClient and archiveClient for one-off queries.
const { liveClient, archiveClient } = useQubic()Sharing your TanStack Query cache
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
<QubicProvider liveClient={live} queryClient={queryClient}>
<App />
</QubicProvider>Wallet connectors (extension / MetaMask Snap)
Setup — WalletProvider
Register your wallet backends once and wrap your app. The primary runnable example below uses the browser extension and MetaMask Snap only. Components call useWallet() with no arguments — no connector plumbing in every component.
import { createLiveClient, createQueryClient } from '@qubic.org/rpc'
import {
QubicProvider,
WalletProvider,
extensionConnector,
createMetaMaskSnapConnector,
} from '@qubic.org/react'
// Create connectors outside the component tree (stable references)
const live = createLiveClient()
const archive = createQueryClient()
const connectors = [
extensionConnector,
createMetaMaskSnapConnector({
broadcast: async (signedBase64) => {
const result = await live.broadcastTransaction(signedBase64)
if (!result.ok) throw result.error
return { networkTxId: result.value.transactionId ?? '', broadcast: result.value }
},
}),
]
export function App() {
return (
<QubicProvider liveClient={live} archiveClient={archive}>
<WalletProvider connectors={connectors}>
<YourApp />
</WalletProvider>
</QubicProvider>
)
}| Prop | Default | Description |
|------|---------|-------------|
| connectors | required | List of WalletConnector instances |
| storageKey | 'qubic-active-connector' | localStorage key for persisting the active connector |
useWallet()
Single hook for all wallet state. Works with any connector registered in WalletProvider.
import { useWallet } from '@qubic.org/react'
function ConnectModal() {
const { connectors, isConnected, isConnecting, account, activeConnector, connect, disconnect, error } = useWallet()
if (isConnected) {
return (
<div>
<p>{account?.name ?? account?.identity}</p>
<p>via {activeConnector?.id}</p>
<button onClick={disconnect}>Disconnect</button>
</div>
)
}
return (
<div>
{connectors.filter(c => c.isAvailable()).map(c => (
<button
key={c.id}
disabled={isConnecting}
onClick={() => connect(c.id)}
>
{c.id}
</button>
))}
{error && <p>{error.message}</p>}
</div>
)
}Signing from any component
function SendPanel() {
const { isConnected, sendTransaction, signMessage } = useWallet()
const handleSend = async () => {
const result = await sendTransaction({
destination: 'BZBQFLLBNCXEMGLOBHUVFTLUPLVCPQUASSILFABOFFBCADQSSUPNWLZBQEXK',
amount: '1000',
})
console.log(result.networkTxId)
}
const handleSign = async () => {
const result = await signMessage('hello qubic')
console.log(result.signatureHex)
}
}Custom connector
Implement WalletConnector to add any other wallet backend:
import type { WalletConnector } from '@qubic.org/react'
export const myConnector: WalletConnector = {
id: 'my-wallet',
isAvailable: () => true,
connect: async () => ({ identity: '…' }),
getAccount: async () => null,
disconnect: async () => {},
sendTransaction: async (_tx) => { throw new Error('not implemented') },
signTransaction: async (_tx) => { throw new Error('not implemented') },
signMessage: async (_msg) => { throw new Error('not implemented') },
on: (_event, _cb) => () => {},
}Lower-level: useWalletConnector(connector) (no provider)
When you only need a single connector and don't want a provider:
import { extensionConnector, useWalletConnector } from '@qubic.org/react'
function ConnectButton() {
const { isAvailable, isConnected, account, connect, disconnect } =
useWalletConnector(extensionConnector)
if (!isAvailable) return <p>Install the Qubic extension</p>
if (!isConnected) return <button onClick={() => connect()}>Connect</button>
return <button onClick={disconnect}>{account?.identity}</button>
}See also
@qubic.org/rpc—createLiveClient,createQueryClient@qubic.org/wallet—createVault,importVault,unlockVault@qubic.org/contracts— typed contract builders, decoders, and readers@qubic.org/events— typed Bob subscription helpers
