decentrl/docs
API Reference

@decentrl/sdk-react

React hooks and providers for building Decentrl-powered UIs with useSyncExternalStore.

npm install @decentrl/sdk-react

Peer 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(): RegisteredClient

Core 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): TSelected
const 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
}): void

Event 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[] },
): void
useEventStream(
  (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 | undefined

Public 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).

HookVariablesResolves toCalls
useCreateGroup(options?)CreateGroupOptions: { alias, history?, mediatorDid? }{ groupDid: string }groups.create
useAcceptGroupInvitation(options?)pendingId: stringvoidgroups.acceptInvitation
useDeclineGroupInvitation(options?)pendingId: stringvoidgroups.declineInvitation
useRequestToJoin(options?){ groupDid: string; note?: string }voidgroups.requestToJoin
useLeaveGroup(options?)groupDid: stringvoidgroups.leave
useDeleteGroupEvent(options?){ groupDid: string; event: RegisteredEvent | GroupEventRef }voidgroups.delete
useInviteToGroup(options?){ groupDid: string; did: string }voidgroups.invite
useApproveJoinRequest(options?){ groupDid: string; pendingId: string }voidgroups.approve
useDeclineJoinRequest(options?){ groupDid: string; pendingId: string }voidgroups.decline
useRemoveFromGroup(options?){ groupDid: string; did: string }voidgroups.remove (the admin rotates at once; staff at the admin's next upkeep)
useSetGroupGrant(options?){ groupDid: string; did: string; plan: StaffGrantPlan }voidgroups.setGrant
useRevokeGroupGrant(options?){ groupDid: string; did: string }voidgroups.revokeGrant
useVouchForMember(options?){ groupDid: string; did: string }voidgroups.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
}