decentrl/docs
SDK

React Hooks

React bindings for Decentrl using useSyncExternalStore for tear-free reads of encrypted state.

@decentrl/sdk-react provides React hooks that connect your components to the Decentrl client. Built on useSyncExternalStore for tear-free concurrent reads.

Installation

npm install @decentrl/sdk-react

Register Your App

Tell the hooks which app they serve, once, next to your app definition:

app.ts
export const chatApp = defineDecentrlApp({ /* events, state, … */ })

declare module '@decentrl/sdk-react' {
  interface Register {
    app: typeof chatApp
  }
}

From then on every hook is typed from that definition, with no generics or casts: useDecentrlState returns your state, usePublish accepts only your event names and their payloads, useEventStream narrows envelopes by type, and DecentrlProvider accepts only this app's client.

Without the registration the hooks still run, but reading state doesn't compile: its type is RegisterYourApp, whose only property spells out the declare module above.

Provider Setup

Wrap your app with DecentrlProvider:

import { DecentrlProvider } from '@decentrl/sdk-react'

const client = chatApp.createClient({ mediatorDid: 'did:web:mediator.decentrl.io' })

function App() {
  return (
    <DecentrlProvider client={client}>
      <Chat />
    </DecentrlProvider>
  )
}

The client prop is typed as the registered app's client. To hold a client in state before it exists, name its type with ReturnType<typeof chatApp.createClient>.

Core Hooks

usePublish()

Returns a typed publish with pending and error state:

import { usePublish } from '@decentrl/sdk-react'

function SendButton({ threadId }: { threadId: string }) {
  const { publish, isPending, error } = usePublish()

  const send = () => publish('message', { text: 'Hello', threadId })   // resolves to { eventId }

  return (
    <>
      <button disabled={isPending} onClick={send}>Send</button>
      {error && <p>Couldn't send: {error.message}</p>}
    </>
  )
}

publish has exactly client.publish's signature — publish(type, data, options?) — and returns its promise, which rejects when publishing fails. error holds the last failure until the next publish starts. To retry a failed publish without creating a second event, pass its event ID: publish('message', data, { eventId }).

useDecentrlClient()

Returns the registered app's client, for anything the hooks don't wrap:

import { useDecentrlClient } from '@decentrl/sdk-react'

const client = useDecentrlClient()
await client.publish('message', data, { recipient: bobDid })

useDecentrlState(selector?)

Subscribes to client state with an optional selector function:

import { useDecentrlState } from '@decentrl/sdk-react'

// Full state
const state = useDecentrlState()

// With selector — only re-renders when messages change
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. s => s.messages.filter(...) returns a new array on every call and re-renders endlessly. Select the slice (or a keyed entry, like s => s.messages[chatId]) and derive with useMemo.

useDecentrlIdentity()

Manages identity state and operations:

import { useDecentrlIdentity } from '@decentrl/sdk-react'

function IdentitySetup() {
  const { identity, isInitialized, create, contracts } = useDecentrlIdentity()

  if (!isInitialized) {
    return (
      <button onClick={() => create({
        alias: 'alice',
        mediatorDid: 'did:web:mediator.decentrl.io',
      })}>
        Create Identity
      </button>
    )
  }

  return <p>DID: {identity?.did}</p>
}

useAutoSync(options?)

Starts automatic background sync. Cleans up on unmount:

import { useAutoSync } from '@decentrl/sdk-react'

function App() {
  useAutoSync({
    intervalMs: 3000,
    websocket: true,
    onError: (err) => console.error(err),
  })

  return <Chat />
}

useConnectionStatus()

const status = useConnectionStatus()
// 'disconnected' | 'connecting' | 'authenticating' | 'connected'

Contract Hooks

import {
  useActiveContracts,
  usePendingContracts,
  useContractByDid,
  useRequestContract,
  useAcceptContract,
  useRevokeContract,
} from '@decentrl/sdk-react'

// List active contracts
const { contracts, refresh, isRefreshing } = useActiveContracts()

// List pending inbound requests
const { data: pending, refetch } = usePendingContracts()

// Find contract by DID
const contract = useContractByDid(bobDid)

// Request a contract (mutation hook)
const { mutate: requestContract, isPending } = useRequestContract()
requestContract({ recipientDid: bobDid })

// Accept a contract (mutation hook)
const { mutate: acceptContract } = useAcceptContract()
acceptContract({
  pendingId: request.id,
  encryptedPayload: request.encryptedPayload,
  requestorEphemeralPublicKey: request.requestorEphemeralPublicKey,
})

// Revoke a contract (mutation hook): silent, Bob isn't told
const { mutate: revokeContract } = useRevokeContract()
revokeContract({ participantDid: bobDid })

The contract hooks are mutations with mutate / mutateAsync and one variables object. The publish hooks aren't: a function generic over the event name can't be one mutation's variables, so they return the typed function itself.

Public Event Hooks

usePublishPublic()

Returns a typed publishPublic with pending and error state:

import { usePublishPublic } from '@decentrl/sdk-react'

function PublishButton() {
  const { publishPublic, isPending } = usePublishPublic()

  return (
    <button
      disabled={isPending}
      onClick={() => publishPublic('blog.post', { title: 'Hello', body: 'World' })}
    >
      Publish
    </button>
  )
}

publishPublic has client.publishPublic's signature and resolves to { publicEventId, eventId }, or rejects. To retry a failed publish without creating a second post, pass the same event ID: publishPublic('blog.post', post, { eventId }).

usePublicEventQuery(options)

Query a publisher's verified public events with pagination:

import { usePublicEventQuery } from '@decentrl/sdk-react'

function PublicFeed({ publisherDid }: { publisherDid: string }) {
  const { data, isLoading, hasMore, fetchMore, refetch } = usePublicEventQuery({
    publisherDid,
    tags: ['featured'],
    pageSize: 10,
  })

  if (isLoading) return <p>Loading...</p>

  return (
    <div>
      {data.map((post) => (
        <article key={post.meta.eventId}>
          {post.type === 'blog.post' && <p>{post.data.title}</p>}
          <small>{new Date(post.meta.timestamp).toLocaleString()}</small>
        </article>
      ))}
      {hasMore && <button onClick={fetchMore}>Load more</button>}
    </div>
  )
}

data holds typed envelopes, newest first — only events signed by the publisher, of one of your publicEvents types, whose data passes the schema. meta.timestamp is unix ms.

OptionTypeRequiredDescription
publisherDidstringYesDID of the publisher to query
tagsstring[]NoFilter by tags (OR semantics)
pageSizenumberNoResults per page (default 20)
enabledbooleanNoDisable the query (default true)

usePublicState(options)

A publisher's state, built by your app's own public: reducers from their newest 500 verified public events — a profile page for someone else, with the same reducers as your own:

import { usePublicState } from '@decentrl/sdk-react'

function Profile({ did }: { did: string }) {
  const { data, isPending, error } = usePublicState({ publisherDid: did })

  if (isPending) return <p>Loading...</p>

  // `profile` and `posts` are slices with public: reducers
  return (
    <div>
      <h1>{data?.profile?.name}</h1>
      {data?.posts.map((post) => <p key={post.id}>{post.title}</p>)}
    </div>
  )
}
OptionTypeRequiredDescription
publisherDidstringYesDID of the publisher
tagsstring[]NoOnly events with these tags
enabledbooleanNoDisable the query (default true)

Slices without public: reducers stay at initial. A new publisherDid or tags fetches again.

Event Hooks

useEventStream(callback, options?)

Subscribe to private events as they are applied (validated, once), with optional filtering. types narrows the envelope:

import { useEventStream } from '@decentrl/sdk-react'

useEventStream(
  (event) => console.log('New:', event.data.text, event.meta.senderDid),   // event.type is 'message'
  { types: ['message'] },
)

useEventQuery(options)

Loads stored events with these tags into the state, page by page, and returns what select picks:

import { useEventQuery } from '@decentrl/sdk-react'

const { data: messages, isLoading, hasMore, fetchMore } = useEventQuery({
  tags: ['thread.abc'],
  select: (state) => state.messages,
  pageSize: 20,
})

Like useDecentrlState, select must return a stable reference.

Group Hooks

Hooks for client.groups. Group events are the registered app's own event types: folded ones reach useDecentrlState through your reducers, with meta.groupDid set. Everything the hooks don't wrap (rotate, hand over, delete a group, cancel a join request) stays on useDecentrlClient().groups.

useGroups()

The current identity's groups, re-rendered when they change:

import { useGroups } from '@decentrl/sdk-react'

function GroupList() {
  const { groups, isSupported } = useGroups()

  if (!isSupported) return <p>This transport has no groups</p>

  return (
    <ul>
      {groups.map((group) => (
        <li key={group.groupDid}>
          {group.alias} {group.status === 'requested' && '(requested)'} {!group.keyed && '(waiting for a key)'}
        </li>
      ))}
    </ul>
  )
}

groups holds joined groups and open join requests (GroupSummary: groupDid, alias, role, status, keyed, handoverOffered). Only joined, keyed groups can be read and posted to. isSupported is false when the client's transport has no groups; every group call would throw GROUPS_NOT_SUPPORTED.

Group Queries

import {
  useGroupCounts,
  useGroupInvitations,
  useGroupJoinRequests,
  useGroupOnline,
  useGroupStaff,
  useGroupView,
} from '@decentrl/sdk-react'

// Invitations to groups not yet joined: [{ pendingId, groupDid, adminDid }]
const { data: invitations } = useGroupInvitations()

// The group as its mediator knows it: members, key version, history policy, admin chain
const { data: view } = useGroupView(groupDid)

// Open requests to join, for the admin or an approver: [{ pendingId, requesterDid, note?, hasNote, createdAt }]
const { data: requests } = useGroupJoinRequests(groupDid)

// The admin and staff: { adminDid, members: [{ did, capabilities, deletePrefixes? }], log }
const { data: staff } = useGroupStaff(groupDid)

// Counts from the group's mediator: a tag counts as a prefix; read again shortly after new events
const { data: counts } = useGroupCounts(groupDid, [
  { id: 'threads', tag: 'stat.thread' },
  { id: 'mine', tag: 'stat.post', senderDid: myDid },
])

// Who is connected right now, polled every minute
const { data: online } = useGroupOnline(groupDid)

They are queries (data, isPending, error, refetch, …) and fetch again whenever the groups change, after a staff action here, and when a group changed elsewhere (an updated hint). useGroupView, useGroupJoinRequests and useGroupStaff read nothing while groupDid is undefined. For a delete button, canDeleteGroupEvent({ staff, viewerDid, authorDid, groupTags }) from @decentrl/sdk says what the mediator will allow. Invitations arrive with syncing; refetch reads them again.

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, like a forum's threads.

import { useState } from 'react'
import { useGroupEvents, useGroupPublish } from '@decentrl/sdk-react'

function Thread({ groupDid, threadId }: { groupDid: string; threadId: string }) {
  const { events, status, isLoading, hasMore, fetchMore } = useGroupEvents({
    groupDid,
    tags: [`thread.${threadId}`],
  })
  const { publish, isPending, error } = useGroupPublish()
  const [text, setText] = useState('')

  if (status !== 'ok') return <p>You can no longer read this group</p>
  if (isLoading) return <p>Loading...</p>

  const send = async () => {
    await publish(groupDid, 'forum.post', { threadId, text })   // resolves to { eventId, seq }
    setText('')
  }

  return (
    <div>
      {events.map((post) => (
        <article key={post.meta.groupSeq}>
          {post.type === 'forum.post' && <p>{post.data.text}</p>}
          <small>{post.meta.senderDid}</small>
        </article>
      ))}
      {hasMore && <button onClick={fetchMore}>Load more</button>}
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button disabled={isPending} onClick={send}>Post</button>
      {error && <p>Couldn't post: {error.message}</p>}
    </div>
  )
}

events are the rows read so far, in the group's order, plus events folded meanwhile that match the read (new posts, your own included). Deleted rows are taken out. Key rows by meta.groupSeq: event IDs are unique only per sender. status is removed or deleted once you can no longer read the group.

OptionTypeRequiredDescription
groupDidstring | undefinedYesThe group; nothing is read while undefined
tagsstring[]NoOnly events carrying any of these group tags; without tags, newest pages first
direction'forward' | 'backward'NoWith tags: forward (default) reads oldest first, backward newest first
pageSizenumberNoEvents per page (default 50)

A new groupDid, tags, direction or pageSize starts the read over.

For small groups and chats, fold instead: give the event a reducer and read it with useDecentrlState. See Fold or Page.

useGroupPublish()

Returns a typed client.groups.publish with pending and error state, like usePublish:

const { publish, isPending, error } = useGroupPublish()
await publish(groupDid, 'forum.post', { threadId, text })

publish(groupDid, type, data, options?) resolves once the post is folded, to { eventId, seq }. Pass the failed post's eventId to retry without a second post.

Group Mutations

import {
  useAcceptGroupInvitation,
  useApproveJoinRequest,
  useCreateGroup,
  useDeclineGroupInvitation,
  useDeclineJoinRequest,
  useDeleteGroupEvent,
  useInviteToGroup,
  useLeaveGroup,
  useRemoveFromGroup,
  useRequestToJoin,
  useRevokeGroupGrant,
  useSetGroupGrant,
  useVouchForMember,
} from '@decentrl/sdk-react'

const { mutateAsync: createGroup } = useCreateGroup()
const { groupDid } = await createGroup({ alias: 'book-club', history: 'full' })

const { mutate: accept } = useAcceptGroupInvitation()
accept(invitation.pendingId)

const { mutate: decline } = useDeclineGroupInvitation()
decline(invitation.pendingId)

const { mutate: requestToJoin } = useRequestToJoin()
requestToJoin({ groupDid, note: 'Alice sent me' })

const { mutate: leave } = useLeaveGroup()
leave(groupDid)

const { mutate: deleteEvent } = useDeleteGroupEvent()
deleteEvent({ groupDid, event: post })   // an event read from the group, or { seq, senderDid, eventId }

// Administering
const { mutate: invite } = useInviteToGroup()
invite({ groupDid, did: bobDid })

const { mutate: approve } = useApproveJoinRequest()
approve({ groupDid, pendingId: request.pendingId })

const { mutate: declineRequest } = useDeclineJoinRequest()
declineRequest({ groupDid, pendingId: request.pendingId })

const { mutate: remove } = useRemoveFromGroup()
remove({ groupDid, did: bobDid })   // the admin rotates at once; a moderator's removal at the admin's next upkeep

// Staff (the admin only)
const { mutate: setGrant } = useSetGroupGrant()
setGrant({ groupDid, did: modDid, plan: { delete: ['forum.news'], remove: true, approve: true } })

const { mutate: revokeGrant } = useRevokeGroupGrant()
revokeGrant({ groupDid, did: modDid })

// A member listed in staff.unaccounted gets no new keys until the admin vouches for it (v0.6)
const { mutate: vouch } = useVouchForMember()
vouch({ groupDid, did: memberDid })

Like the contract hooks, these have mutate / mutateAsync and one variables value: an object, or the pendingId / groupDid string where one value is enough.