@decentrl/sdk-react
React hooks and providers for building Decentrl-powered UIs with useSyncExternalStore.
npm install @decentrl/sdk-reactPeer dependency: React 18+ or 19+.
Registration
Register
Registers your app with the hooks, once per program:
declare module '@decentrl/sdk-react' {
interface Register {
app: typeof myApp
}
}Every hook below is then typed from that app, with no generics. The types they use are exported too:
type RegisteredApp // the app's AppTypes
type RegisteredClient // DecentrlClient<RegisteredApp>
type RegisteredState // StateOf<RegisteredApp>, or RegisterYourApp when nothing is registered
type RegisteredEventName // EventName<RegisteredApp>
type RegisteredEvent<K> // EventOf<RegisteredApp, K>
type RegisteredPublicEvent // PublicEventOf<RegisteredApp>Without a registration, RegisteredState is RegisterYourApp: reading a slice fails to compile, and the error names the declare module to add. Publishing still works, loosely typed.
Provider
DecentrlProvider
Wraps your app with the registered app's client.
import { DecentrlProvider } from '@decentrl/sdk-react'
<DecentrlProvider client={client}>
<App />
</DecentrlProvider>interface DecentrlProviderProps {
client: RegisteredClient // a client of another app doesn't compile
children: ReactNode
}useDecentrlClient()
Access the client from context. Throws if used outside DecentrlProvider.
function useDecentrlClient(): RegisteredClientCore Hooks
useDecentrlState(selector?)
Subscribes to client state with optional selector. Uses useSyncExternalStore for tear-free reads.
function useDecentrlState(): RegisteredState
function useDecentrlState<TSelected>(selector: (state: RegisteredState) => TSelected): TSelectedconst messages = useDecentrlState(s => s.messages)
const count = useDecentrlState(s => s.messages.length)The selector must return the same reference for the same state (select, don't build): a selector that returns a new array or object on every call re-renders endlessly.
useDecentrlIdentity()
Manages identity state and operations.
function useDecentrlIdentity(): {
identity: IdentityState | null
isInitialized: boolean
create: (opts: { alias: string; mediatorDid: string }) => Promise<IdentityState>
load: (serialized: SerializedIdentity) => IdentityState
serialize: () => SerializedIdentity
reset: () => void
contracts: ContractManager
}useConnectionStatus()
function useConnectionStatus(): ConnectionStatus
// 'disconnected' | 'connecting' | 'authenticating' | 'connected'useAutoSync(options?)
Starts automatic background sync. Cleans up on unmount.
function useAutoSync(options?: {
intervalMs?: number // default: 3000
enabled?: boolean // default: true
onError?: (error: unknown) => void
websocket?: boolean
fallbackIntervalMs?: number
}): voidEvent Hooks
useEventStream(callback, options?)
Subscribes to real-time events with optional type/tag filtering.
function useEventStream<K extends RegisteredEventName = RegisteredEventName>(
callback: (envelope: RegisteredEvent<K>) => void,
options?: { types?: K[]; tags?: string[] },
): voiduseEventStream(
(event) => console.log('New message:', event.data.content), // narrowed to 'chat.message'
{ types: ['chat.message'] },
)The callback gets each private event as it is applied: validated and once.
useEventQuery(options)
Queries historical events with pagination and state selection.
function useEventQuery<TSelected>(options: {
tags?: string[]
select: (state: RegisteredState) => TSelected // must return a stable reference
pageSize?: number // default: 20
queryOptions?: Omit<QueryOptions, 'tags' | 'pagination'>
}): {
data: TSelected
isLoading: boolean
error: Error | null
hasMore: boolean
fetchMore: () => void
}Contract Hooks
useActiveContracts()
function useActiveContracts(): {
contracts: StoredSignedContract[]
refresh: () => void
refreshAsync: () => Promise<void>
isRefreshing: boolean
isError: boolean
error: Error | null
}usePendingContracts(options?)
Inbound contact requests. Group invitations are left out: see useGroupInvitations.
function usePendingContracts(
options?: { enabled?: boolean },
): {
data: PendingContractRequest[] | undefined
isPending: boolean
isError: boolean
error: Error | null
refetch: () => void
}useContractByDid(did)
function useContractByDid(did: string): StoredSignedContract | undefinedPublic Event Hooks
usePublicEventQuery(options)
One publisher's verified public events, newest first, page by page.
function usePublicEventQuery(options: {
publisherDid: string
tags?: string[]
pageSize?: number // default: 20
enabled?: boolean // default: true
}): {
data: RegisteredPublicEvent[] // typed envelopes: signed by the publisher, declared type, valid data
isLoading: boolean
error: Error | null
hasMore: boolean
fetchMore: () => void
refetch: () => void
}usePublicState(options)
A publisher's state, built by the registered app's public: reducers from their newest 500 verified public events (client.readPublicState).
function usePublicState(options: {
publisherDid: string
tags?: string[]
enabled?: boolean // default: true
}): {
data: RegisteredState | undefined
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}const { data } = usePublicState({ publisherDid: did })
const posts = data?.posts ?? []Mutation Hooks
usePublish()
Publishes a private event.
function usePublish(): {
publish: RegisteredClient['publish'] // (type, data, options?) => Promise<{ eventId: string }>
isPending: boolean
error: Error | null // the last failure, cleared when the next publish starts
}const { publish, isPending } = usePublish()
await publish('chat.message', { chatId, content })publish has exactly client.publish's signature and is stable across renders. Its promise rejects when publishing fails. Pass the failed publish's eventId in PublishOptions to retry without creating a second event.
Unlike the contract hooks there is no mutate/mutateAsync: a function generic over the event name can't be expressed as one mutation's variables.
usePublishPublic()
Publishes a public event.
function usePublishPublic(): {
publishPublic: RegisteredClient['publishPublic'] // (type, data, options?) => Promise<{ publicEventId, eventId }>
isPending: boolean
error: Error | null
}eventId is the signed event ID; publishing it again returns the existing post. publicEventId is what client.deletePublicEvent takes.
useCreateIdentity(options?)
function useCreateIdentity(options?): {
mutate: (vars: { alias: string; mediatorDid: string }) => void
mutateAsync: (vars) => Promise<IdentityState>
isPending: boolean
isSuccess: boolean
isError: boolean
error: Error | null
}useRequestContract(options?)
function useRequestContract(options?): {
mutate: (vars: { recipientDid: string }) => void
mutateAsync: (vars) => Promise<void>
isPending: boolean
// ...
}useAcceptContract(options?)
function useAcceptContract(options?): {
mutate: (vars: {
pendingId: string
encryptedPayload: string
requestorEphemeralPublicKey: string
}) => void
mutateAsync: (vars) => Promise<void>
isPending: boolean
// ...
}useRevokeContract(options?)
Revokes a contract silently: the participant is not told and is treated as a stranger from then on.
function useRevokeContract(options?): {
mutate: (vars: { participantDid: string }) => void
mutateAsync: (vars) => Promise<void>
isPending: boolean
// ...
}useSync(options?)
Manually trigger a sync.
function useSync(options?): {
mutate: () => void
mutateAsync: () => Promise<void>
isPending: boolean
// ...
}Group Hooks
Hooks for client.groups (Groups). Operations without a hook (cancelJoinRequest, rotate, handOver, acceptHandover, deleteGroup, read, onDeletion) stay on useDecentrlClient().groups.
useGroups()
The current identity's groups, from client.groups.getGroups() through useSyncExternalStore. Refreshes them on mount.
function useGroups(): {
groups: GroupSummary[] // joined groups and open join requests
isSupported: boolean // false: the transport has no groups, and every group call throws GROUPS_NOT_SUPPORTED
}useGroupInvitations(options?)
Invitations to groups not yet joined. Fetches again whenever the groups change; new invitations arrive with syncing, and refetch reads them again.
function useGroupInvitations(
options?: { enabled?: boolean; key?: string },
): {
data: GroupInvitationSummary[] | undefined // { pendingId, groupDid, adminDid }
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}useGroupView(groupDid)
The group as its mediator knows it (client.groups.view). Nothing is read while groupDid is undefined; fetches again whenever the groups change.
function useGroupView(groupDid: string | undefined): {
data: GroupView | undefined
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}useGroupJoinRequests(groupDid)
Open requests to join a group, for its admin or an approver (client.groups.pendingJoinRequests). Nothing is read while groupDid is undefined; fetches again whenever the groups change.
function useGroupJoinRequests(groupDid: string | undefined): {
data: GroupJoinRequestSummary[] | undefined // { pendingId, requesterDid, note?, hasNote, createdAt }
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}useGroupStaff(groupDid)
The group's admin and staff with their capabilities, from the grant log this member verified (client.groups.staff). Nothing is read while groupDid is undefined; fetches again whenever the groups change, after a staff action, and when a group changed elsewhere.
function useGroupStaff(groupDid: string | undefined): {
data: GroupStaff | undefined // { adminDid, members: [{ did, capabilities, deletePrefixes? }], log, unaccounted? }
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}useGroupCounts(groupDid, queries)
Live events in a group matching each query, by its id (client.groups.counts). Nothing is read while groupDid is undefined or queries is empty; fetches again whenever the groups change, and COUNTS_REFRESH_DELAY_MS (1 s) after the last of a burst of events or deletions in the group. A new set of queries is a new read.
function useGroupCounts(
groupDid: string | undefined,
queries: GroupCountQuery[], // [{ id, tag?, senderDid?, afterSeq? }]
): {
data: Record<string, number> | undefined
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}useGroupOnline(groupDid)
The members connected to the group's mediator right now (client.groups.online). Presence has no hint, so it is read again every ONLINE_POLL_MS (60 s) while mounted, and whenever the groups change.
function useGroupOnline(groupDid: string | undefined): {
data: string[] | undefined
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: Error | null
refetch: () => void
}useGroupEvents(options)
A group's events read page by page with client.groups.read, not folded into the state: for streams too large to keep there.
interface UseGroupEventsOptions {
groupDid: string | undefined // nothing is read while undefined
tags?: string[] // any of these group tags; without, newest pages first
direction?: 'forward' | 'backward' // with tags: forward (default) is oldest first
pageSize?: number // default: 50
}
function useGroupEvents(options: UseGroupEventsOptions): {
events: RegisteredEvent[] // rows read plus live ones, in group order, deleted ones left out
status: 'ok' | 'removed' | 'deleted' // removed or deleted: the group can no longer be read
isLoading: boolean // the first page is on its way
isFetching: boolean
error: Error | null
hasMore: boolean // more pages in the read's direction
fetchMore: () => void
}Events folded while the hook is mounted (new posts, own ones included) join events when they match the group and tags; deletions (client.groups.onDeletion) take rows out. Rows are one per meta.groupSeq. A new groupDid, tags, direction or pageSize starts over.
useGroupPublish()
Posts one of the registered app's events to a group.
function useGroupPublish(): {
publish: RegisteredClient['groups']['publish'] // (groupDid, type, data, options?: { eventId? }) => Promise<{ eventId, seq }>
isPending: boolean
error: Error | null // the last failure, cleared when the next post starts
}publish resolves once the post is folded into the state. Like usePublish, there is no mutate: the function is generic over the event name.
Group Mutations
Each returns the useDecentrlMutation shape (mutate, mutateAsync, isPending, isSuccess, isError, isIdle, data, error, reset) and takes its options (onSuccess, onError, onSettled).
| Hook | Variables | Resolves to | Calls |
|---|---|---|---|
useCreateGroup(options?) | CreateGroupOptions: { alias, history?, mediatorDid? } | { groupDid: string } | groups.create |
useAcceptGroupInvitation(options?) | pendingId: string | void | groups.acceptInvitation |
useDeclineGroupInvitation(options?) | pendingId: string | void | groups.declineInvitation |
useRequestToJoin(options?) | { groupDid: string; note?: string } | void | groups.requestToJoin |
useLeaveGroup(options?) | groupDid: string | void | groups.leave |
useDeleteGroupEvent(options?) | { groupDid: string; event: RegisteredEvent | GroupEventRef } | void | groups.delete |
useInviteToGroup(options?) | { groupDid: string; did: string } | void | groups.invite |
useApproveJoinRequest(options?) | { groupDid: string; pendingId: string } | void | groups.approve |
useDeclineJoinRequest(options?) | { groupDid: string; pendingId: string } | void | groups.decline |
useRemoveFromGroup(options?) | { groupDid: string; did: string } | void | groups.remove (the admin rotates at once; staff at the admin's next upkeep) |
useSetGroupGrant(options?) | { groupDid: string; did: string; plan: StaffGrantPlan } | void | groups.setGrant |
useRevokeGroupGrant(options?) | { groupDid: string; did: string } | void | groups.revokeGrant |
useVouchForMember(options?) | { groupDid: string; did: string } | void | groups.vouch (the admin accounts for a member in staff.unaccounted) |
const { mutate: approve } = useApproveJoinRequest()
approve({ groupDid, pendingId: request.pendingId })Generic Primitives
These power all the hooks above and can be used for custom async operations:
useDecentrlQuery(queryFn, options?)
function useDecentrlQuery<TData, TError>(
queryFn: () => Promise<TData>,
options?: {
enabled?: boolean
key?: string // what the query reads: a new key fetches again, and a stale answer is ignored
},
): {
data: TData | undefined
isPending: boolean
isFetching: boolean
isSuccess: boolean
isError: boolean
error: TError | null
refetch: () => void
}useDecentrlMutation(mutationFn, options?)
function useDecentrlMutation<TVariables, TData, TError>(
mutationFn: (variables: TVariables) => Promise<TData>,
options?: {
onSuccess?: (data: TData, variables: TVariables) => void
onError?: (error: TError, variables: TVariables) => void
onSettled?: (data: TData | undefined, error: TError | null, variables: TVariables) => void
},
): {
mutate: (variables: TVariables) => void
mutateAsync: (variables: TVariables) => Promise<TData>
isPending: boolean
isSuccess: boolean
isError: boolean
isIdle: boolean
data: TData | undefined
error: TError | null
reset: () => void
}