ReactuseInvitations

useInvitations

Returns the paginated invitation list for a group plus a live event subscription that keeps it in sync with member.invited and member.joined events.

import { useInvitations } from "@junjo/react";
 
function PendingInvites({ groupId }: { groupId: GroupId }) {
  const { invitations, loading, error, hasMore, fetchMore, loadingMore } =
    useInvitations(groupId);
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <ul>
      {invitations.map((i) => (
        <li key={i.id}>{i.code}</li>
      ))}
      {hasMore ? (
        <li>
          <button type="button" onClick={fetchMore} disabled={loadingMore}>
            {loadingMore ? "Loading more..." : "Load more"}
          </button>
        </li>
      ) : null}
    </ul>
  );
}

Signature

function useInvitations(groupId: GroupId, opts?: UseInvitationsOptions): UseInvitationsResult;
 
interface UseInvitationsOptions {
  status?: "pending" | "used" | "expired" | "all";
  limit?: number;
}
 
interface UseInvitationsResult {
  invitations: Invitation[];
  loading: boolean;
  loadingMore: boolean;
  hasMore: boolean;
  error: Error | null;
  refetch: () => Promise<void>;
  fetchMore: () => Promise<void>;
  applyOptimistic: (updater: (prev: Invitation[]) => Invitation[]) => () => void;
}
FieldMeaning
invitationsThe current list, filtered by status. Server order on the initial page; new pages append at the end; live events insert / remove / update in place.
loadingtrue from mount until the first invitations.list response (success or error).
loadingMoretrue while a fetchMore request is in flight. Becomes false again once it resolves or errors.
hasMoretrue when the last page returned a non-null nextCursor. Becomes false after the final page lands.
errorThe most recent error: a fetch error, a fetchMore error, or a streaming error. Stays set until the next refetch clears it.
refetchResets state and re-runs the first page, discarding the cursor. Returns a Promise that resolves when the new page lands.
fetchMoreLoads the next page using the stored cursor and appends new entries. No-op when hasMore is false or another fetchMore is already in flight.
applyOptimisticApplies an updater to the local invitations array immediately and returns a rollback closure that restores the pre-update snapshot. See Optimistic updates below.

Status filter

The status option defaults to "pending", which is the dominant case (a “Pending Invitations” panel that should not show invitations that have already been accepted, declined, or that have expired). The other valid values are "used", "expired", or "all".

The filter combines two layers:

  1. Server-side narrowing. The hook maps the status to the includeExpired and includeUsed flags on junjo.invitations.list:

    statusServer flags
    pending{} (server default excludes both)
    used{ includeUsed: true }
    expired{ includeExpired: true }
    all{ includeExpired: true, includeUsed: true }
  2. Client-side narrowing. After the page returns, the hook applies a matcher that keeps only rows matching the requested status. For pending, this is usedAt === null && (expiresAt === null || expiresAt > now). For used, usedAt !== null. For expired, expiresAt !== null && expiresAt <= now. For all, every row passes.

The two-layer design exists because the server’s flags are inclusive (“also include expired” / “also include used”), not exclusive. Asking for “only used” still requires a client-side filter to drop the pending rows the server returned alongside the used ones.

Changing the status between renders triggers a refetch (the cursor resets and a fresh first page lands with new server flags) but does NOT re-open the SSE subscription.

Pagination

Pagination is cursor-based. The hook calls junjo.invitations.list(groupId, opts) for each page; the first call carries { ...statusFlags, limit? } (no cursor) and subsequent fetchMore calls carry { ...statusFlags, cursor, limit? }. The server’s response shape is { items: Invitation[]; nextCursor: string | null }; hasMore mirrors nextCursor !== null.

fetchMore is idempotent on duplicate calls: the second concurrent invocation returns immediately without firing another network request. It is also a no-op once hasMore is false. The hook deduplicates by id when appending.

Live updates

After the first page lands, the hook opens an SSE subscription scoped to the group. Two event types modify state:

EventBehavior
member.invitedIf the new invitation matches the current filter, append at the end (or replace in-place when the same id is already present, idempotent on dedupe). If the filter excludes it, ignore.
member.joinedFor each direct invitation in state where targetUserId === event.userId && usedAt === null, set usedAt to event.occurredAt and usedBy to event.userId. If the updated invitation no longer matches the current filter (e.g. pending no longer applies because it is now used), remove it; otherwise keep it updated in place.

Other events (role.created, role.deleted, member.left, permission.granted, permission.revoked, group.updated, group.deleted, role.changed, group.relationship.changed) are ignored and do not cause re-renders.

The subscription is tied to (junjo, groupId). Changing status does not re-open it. Changing groupId closes the old subscription and opens a new one.

V1 limitations on live updates

  • Open-code invitations are NOT auto-updated on accept. The member.joined event identifies the user who joined but not the invitation they used. Direct invitations are correlated by matching targetUserId === event.userId. Open-code invitations (those with targetUserId: null) cannot be correlated; they stay in the list until the next refetch. Call refetch after junjo.groups.acceptInvitation if your UI needs precise lifecycle tracking on open codes.
  • Revoke is not an event. The server publishes no event when an invitation is revoked. After calling junjo.invitations.revoke(code), call refetch to drop the row from the list.
  • Decline is not an event. Same caveat as revoke: the server publishes no event on decline; refetch to update.
  • Expiration is not auto-tracked. An invitation whose expiresAt passes during the lifetime of the hook stays in the visible list with status pending until the next refetch. The hook does not run a timer to re-evaluate the matcher on the wall clock. Call refetch if your UI needs to surface expiration changes between user actions.

Errors

The hook never throws; errors land in result.error. Three sources:

  • Initial fetch error: a JunjoError (or other) from the first invitations.list call. The hook stays at loading: false with invitations: [].
  • fetchMore error: appended to error while invitations keeps the existing snapshot. loadingMore flips back to false. Calling fetchMore again retries with the same cursor.
  • Streaming error: a thrown subscribe handshake error or a mid-stream error reported through onError. The current snapshot stays intact; only error flips. The hook does NOT auto-reconnect; consumers can call refetch to recover the snapshot or trigger a remount to reopen the SSE.

If useInvitations is called outside a <JunjoProvider>, it throws synchronously with the same descriptive message as useJunjo.

Optimistic updates

applyOptimistic(updater) lets a mutation flip the local invitation list before the server confirms. It runs updater(state.invitations) and replaces invitations with the result; it returns a rollback closure that restores the pre-update snapshot if the mutation fails. Pair it with useMutation so the snapshot lives inside the mutation’s context:

import { useInvitations, useJunjo, useMutation } from "@junjo/react";
 
function RevokeButton({ groupId, code }: { groupId: GroupId; code: string }) {
  const junjo = useJunjo();
  const { applyOptimistic } = useInvitations(groupId);
 
  const { mutate, isPending } = useMutation<void, Error, void, { rollback: () => void }>({
    mutationFn: () => junjo.invitations.revoke(code),
    onMutate: () => ({
      rollback: applyOptimistic((prev) => prev.filter((i) => i.code !== code)),
    }),
    onError: (_err, _vars, ctx) => ctx?.rollback(),
  });
 
  return (
    <button type="button" onClick={() => mutate()} disabled={isPending}>
      Revoke
    </button>
  );
}

The same shape covers an optimistic prepend on invite-by-user-id (so the new invitation shows up immediately while the server is contacted):

function InviteButton({
  groupId,
  targetUserId,
}: {
  groupId: GroupId;
  targetUserId: UserId;
}) {
  const junjo = useJunjo();
  const { applyOptimistic } = useInvitations(groupId);
 
  const { mutate } = useMutation<Invitation, Error, void, { rollback: () => void }>({
    mutationFn: () => junjo.groups.inviteByUserId(groupId, targetUserId),
    onMutate: () => {
      const placeholder: Invitation = {
        id: `pending_${targetUserId}` as InvitationId,
        groupId,
        code: "",
        roleId: null,
        targetUserId,
        createdBy: null,
        createdAt: new Date(),
        expiresAt: null,
        usedAt: null,
        usedBy: null,
      };
      return { rollback: applyOptimistic((prev) => [placeholder, ...prev]) };
    },
    onError: (_err, _vars, ctx) => ctx?.rollback(),
  });
 
  return <button type="button" onClick={() => mutate()}>Invite</button>;
}

After a successful invite, the member.invited event arrives over SSE and replaces the placeholder by id-based dedupe; if the placeholder’s id does not match the server-issued id, the live event simply appends the real invitation in place and the next refetch reconciles the list. For exact-match dedupe under optimistic-prepend patterns, prefer constructing the placeholder with a stable id you control on both sides, or call refetch from onSuccess.

How the snapshot interacts with SSE events

After applyOptimistic runs, the hook keeps applying SSE events (member.invited, member.joined) on top of the optimistic state. A successful revoke does not emit an event, so the optimistic removal stays applied indefinitely; if the mutation fails and you roll back, the row reappears in the list, ready to retry.

Rollback restores the pre-update snapshot exactly

The rollback closure stores the invitations array as it was when applyOptimistic was called and restores it verbatim. SSE events that arrived between the optimistic update and the rollback are dropped on rollback. The mutation window is short enough that this is rare in practice; if your UI is sensitive to it, call refetch() from onError after rollback().

Concurrent overlapping mutations

Multiple in-flight mutations rolling back in arbitrary order get LIFO snapshot-restore semantics: each rollback restores to the snapshot taken at its applyOptimistic call. Rolling back an earlier mutation can therefore overwrite the optimistic state of a later one. Matches React Query’s mutation rollback behavior.

applyOptimistic does not call the SDK

The hook only mutates local state; the network request is whatever you put in mutationFn. Any mutation (revoke, invite, decline, custom server route) can wire optimistic UI through the same primitive without the hook needing per-method support.

Composing with useGroup and useMembers

Each list hook opens its own SSE subscription. If a screen renders both useInvitations and useGroup (or useMembers) for the same groupId, two or three parallel streams open against the server. This is correct (the server tolerates duplicate subscriptions per group) but wasteful for multi-consumer pages. A shared subscription cache that deduplicates streams under one provider is a post-V1 idea.

Testing

The hook talks to the SDK exclusively through useJunjo(). Stub junjo.invitations.list and junjo.groups.subscribe directly on the instance:

import { Junjo } from "@junjo/sdk";
import { JunjoProvider } from "@junjo/react";
import { vi } from "vitest";
 
const client = new Junjo({
  apiKey: "test_prefix.test_secret",
  fetch: vi.fn() as unknown as typeof fetch,
});
Object.assign(client.invitations, {
  list: vi.fn().mockResolvedValue({ items: [], nextCursor: null }),
});
Object.assign(client.groups, {
  subscribe: vi.fn().mockResolvedValue({ close: vi.fn() }),
});