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. The subscription rides the provider’s shared group event stream, so it adds no connection cost next to the other group hooks.

import { useInvitations } from "@junjo.io/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. For used / expired / all this describes the fetched superset, not the visible list; see Status filter.
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 three concrete statuses partition invitations disjointly:

  • pending: unused and unexpired (usedAt === null and expiresAt is null or in the future).
  • used: any redeemed invitation (usedAt !== null), even one whose expiry has since passed. A used-and-expired invitation counts as used, never as expired.
  • expired: unused but past its expiresAt (usedAt === null && expiresAt <= now).

The filter combines two layers:

  1. Server-side narrowing. The server’s list endpoint has no exclusive status filter. It exposes two additive flags, includeExpired and includeUsed, which lift its default exclusion of expired and used rows. The hook maps status to the narrowest superset request on junjo.invitations.list:

    statusServer flags
    pending{} (the server’s default exclusions already express “pending”)
    used{ includeExpired: true, includeUsed: true }
    expired{ includeExpired: true }
    all{ includeExpired: true, includeUsed: true }

    used needs both flags: includeUsed alone is not enough, because a used invitation whose expiresAt has since passed is still dropped by the expired-row exclusion. Both flags must be lifted to receive every used row.

  2. Client-side narrowing. After each page returns, the hook applies a matcher that keeps only rows in the requested partition (using the definitions above, evaluated against the current clock). This layer is what the server cannot express: the flags are inclusive (“also include expired” / “also include used”), not exclusive, so asking for “only used” still requires dropping the pending rows the server returned alongside the used ones. For pending the server response already matches; for all every row passes.

Because pagination happens before the client-side layer, hasMore and the cursor describe the fetched superset, not the visible list: for used, expired, and all, a page can contribute zero (or few) visible rows while hasMore stays true, and several fetchMore calls may be needed to surface the next visible row. For pending, the server’s defaults already match the partition, so hasMore effectively describes the pending stream.

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

On mount, the hook attaches to the group’s shared event stream. Incoming events are matched against the status partition client-side, so live updates respect the filter. 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 (client, groupId). Changing status does not re-open it. Changing groupId detaches from the old group’s shared stream and attaches to the 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: the shared stream failed (handshake rejection or mid-stream drop) or was closed cleanly by the server; a clean close surfaces as JunjoStreamClosedError (check with isStreamClosedError). The current snapshot stays intact; only error flips. The hook does NOT auto-reconnect; remount or change groupId to resubscribe (refetch refreshes the snapshot but does not reopen the stream). See Stream teardown for the full contract.

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.io/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

The group hooks share one SSE stream per (client, groupId) through the provider’s subscription hub. A screen rendering useInvitations next to useGroup, useMembers, or useRoles for the same groupId still costs one server connection; each hook keeps its own snapshot fetches and its own state.

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.io/sdk";
import { JunjoProvider } from "@junjo.io/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() }),
});