ReactuseGroup

useGroup

Live, snapshot-plus-stream view of a single group and its active members. Fetches the group and the first page of active members up-front, then attaches to the group’s shared SSE event stream and applies incoming events to the cached state. Further member pages load on demand via fetchMoreMembers. The hook detaches from the shared stream on unmount or when groupId changes.

import { useGroup } from "@junjo.io/react";
 
function GuildPanel({ groupId }: { groupId: GroupId }) {
  const { group, members, loading, error, refetch, membersHasMore, fetchMoreMembers } =
    useGroup(groupId);
 
  if (loading) return <Spinner />;
  if (error) return <ErrorBanner error={error} onRetry={refetch} />;
  if (!group) return <Empty />;
 
  return (
    <section>
      <h1>{group.name}</h1>
      <MemberList members={members} />
      {membersHasMore ? (
        <button type="button" onClick={fetchMoreMembers}>
          Load more members
        </button>
      ) : null}
    </section>
  );
}

Signature

function useGroup(groupId: GroupId): UseGroupResult;
 
interface UseGroupResult {
  group: Group | null;
  members: Member[];
  loading: boolean;
  error: Error | null;
  membersHasMore: boolean;
  refetch: () => Promise<void>;
  fetchMoreMembers: () => Promise<void>;
  applyOptimistic: (
    updater: (prev: { group: Group | null; members: Member[] }) => {
      group: Group | null;
      members: Member[];
    },
  ) => () => void;
}
FieldTypeNotes
groupGroup | nullThe group on success. null while loading, after a 404, or after a group.deleted event arrives on the live stream.
membersMember[]Active members (status "active", filtered server-side) accumulated so far. May be a partial roster: only the first page loads on mount, so check membersHasMore and call fetchMoreMembers until it turns false to exhaust the roster. Updated live by member.joined / member.left / role.changed / member.banned / member.unbanned events.
loadingbooleantrue until the initial fetch resolves (success or error). Flips back to true on refetch, but the hook keeps the last snapshot visible while the new fetch is in flight.
errorError | nullThe fetch error, a fetchMoreMembers error, or a streaming error (a server-initiated close surfaces as JunjoStreamClosedError). JunjoError instances pass through; cast with instanceof JunjoError if you want the typed code / status.
membersHasMorebooleantrue while more active-member pages exist beyond those already loaded.
refetch() => Promise<void>Re-runs the group + first-members-page fetch, resetting the member cursor. Clears error on entry. The promise resolves once state has been dispatched; errors flow into error, never thrown. Does NOT reopen the event stream (it never did; see Errors).
fetchMoreMembers() => Promise<void>Loads the next page of active members and appends it to members (deduplicated by userId). No-op while membersHasMore is false or another page request is in flight.
applyOptimistic(updater) => () => voidApplies an updater to a { group, members } snapshot immediately and returns a rollback closure that restores the pre-update snapshot. See Optimistic updates below.

Behavior

  • Initial fetch. Issues groups.get(groupId) and junjo.members.list(groupId, { status: ["active"] }) in parallel. The active-status filter runs server-side, so the member cursor and membersHasMore describe the active-member stream (a client-side filter would let a page of non-active rows read as a truncated roster with a live “load more”).
  • Member pagination. Only the first member page loads up-front. fetchMoreMembers fetches the next page with the stored cursor (still status: ["active"]) and appends it, deduplicated by userId; membersHasMore mirrors nextCursor !== null. Page requests are generation-guarded: a refetch or groupId change invalidates any in-flight page so a stale response cannot land on the new state, and a second concurrent fetchMoreMembers returns immediately without a network request.
  • Subscription. After the initial fetch resolves, the hook attaches to the group’s shared event stream (fetch-then-subscribe ordering is deliberate: an event landing on the empty pre-fetch state would be clobbered by the fetch result). Events are applied to local state synchronously; the hook detaches on unmount and on groupId change, and the provider’s hub closes the underlying stream when the last hook detaches.
  • Race protection. A monotonic generation counter discards stale fetch results when groupId changes mid-flight or refetch is called while another fetch is pending.
  • Snapshot durability. A streaming error or server close sets error but leaves group and members unchanged so the UI can keep rendering the last-known state. refetch refreshes the snapshot; remount or change groupId to resubscribe to events.
  • No replay across reconnects. Per the V1 SSE contract, events that fire while no subscription is active are not replayed; if you suspect a gap, call refetch for an authoritative snapshot.

Event handling

EventEffect on state
member.joinedAppends event.member to members. If a member with the same userId already exists, replaces that entry in place.
member.leftRemoves any member with the matching userId.
role.changedFinds the member with event.userId; replaces their roles with (roles - removed) + added. Members not currently in the roster are ignored.
member.banned / member.unbannedRemoves any member with the matching userId from the roster, live. The roster is active-only; a ban flips the row to "banned" and an unban to "left" (the server’s unban handler sets "left", not "active": the member has to rejoin), and neither is active. The events carry no member snapshot, so rows not present cannot be inserted.
group.updatedReplaces group with event.group.
group.deletedSets group to null, clears members, and resets member pagination: membersHasMore flips to false and the stored cursor is discarded, so a later fetchMoreMembers cannot fire with a dead cursor. The subscription stays open for any subsequent group.updated (e.g., after a restore).
Other event typesIgnored. Role / permission CRUD events that do not affect group identity or member roster have no effect on this hook’s state.

Errors

The hook never throws to the caller. Both fetch errors and streaming errors land in error:

  • groups.get or members.list rejects -> error set, loading -> false. The prior group / members snapshot is left intact (relevant on a failed refetch; on mount it is simply empty). JunjoError instances pass through unchanged.
  • The SSE handshake fails (typically permission_denied or a network error) or the stream drops mid-flight -> error set with the underlying error; group and members are not touched.
  • The server closes the stream cleanly (a deploy, a proxy idle timeout) -> error set to JunjoStreamClosedError (check with isStreamClosedError); the snapshot is still valid, only the live feed has stopped.

refetch recovers the snapshot from a fetch error and clears error before retrying, but it does NOT reopen the event stream (it never did). After a streaming error or a server close there is no automatic reconnect, because the server has no event replay; remount the hook or change groupId to resubscribe. See Stream teardown for the full contract.

Optimistic updates

applyOptimistic(updater) lets a mutation flip the local group and members snapshot before the server confirms. The updater receives a { group, members } snapshot and must return a new { group, members } value. The hook returns a rollback closure that restores the pre-update snapshot if the mutation fails. Pair it with useMutation:

import { useGroup, useJunjo, useMutation } from "@junjo.io/react";
 
function KickButton({ groupId, userId }: { groupId: GroupId; userId: UserId }) {
  const junjo = useJunjo();
  const { applyOptimistic } = useGroup(groupId);
 
  const { mutate, isPending } = useMutation<void, Error, void, { rollback: () => void }>({
    mutationFn: () => junjo.members.kick(groupId, userId),
    onMutate: () => ({
      rollback: applyOptimistic((prev) => ({
        group: prev.group,
        members: prev.members.filter((m) => m.userId !== userId),
      })),
    }),
    onError: (_err, _vars, ctx) => ctx?.rollback(),
  });
 
  return <button type="button" onClick={() => mutate()} disabled={isPending}>Kick</button>;
}

The same primitive covers an optimistic group rename (touching prev.group while leaving prev.members untouched):

function RenameButton({ groupId, name }: { groupId: GroupId; name: string }) {
  const junjo = useJunjo();
  const { applyOptimistic } = useGroup(groupId);
 
  const { mutate } = useMutation<Group, Error, void, { rollback: () => void }>({
    mutationFn: () => junjo.groups.update(groupId, { name }),
    onMutate: () => ({
      rollback: applyOptimistic((prev) => ({
        group: prev.group ? { ...prev.group, name } : null,
        members: prev.members,
      })),
    }),
    onError: (_err, _vars, ctx) => ctx?.rollback(),
  });
 
  return <button type="button" onClick={() => mutate()}>Rename</button>;
}

A single call can update group and members together (e.g., kick a member and bump memberCount in lockstep), which is why the snapshot is a single object instead of two methods.

Why one method covers both fields

group and members are two slices of the same logical entity. A single atomic snapshot avoids the trap of an inconsistent intermediate state when a mutation needs to update both (e.g., a kick that should drop both the member row and the cached group.memberCount). Spread prev.group and prev.members through unchanged when you only mean to touch one.

How the snapshot interacts with SSE events

After applyOptimistic runs, the hook keeps applying SSE events on top of the optimistic state. A successful kick emits member.left, which the reducer’s member.left handler treats as a no-op when the user is already absent; a successful rename emits group.updated with the authoritative group payload. The optimistic state and the live stream converge on success.

Rollback restores the pre-update snapshot exactly

The rollback closure stores { group, members } as it was when applyOptimistic was called, and restores both fields 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 (kick, rename, role assignment, custom server route) can wire optimistic UI through the same primitive.

Multiple consumers

Two useGroup(sameId) calls inside the same provider share one SSE stream through the subscription hub, but still run two snapshot fetches and hold two independent copies of state. Promote the hook into a parent component and pass group / members down if you need a single source of truth; a shared snapshot cache is a post-V1 idea and would be transparent to callers when it lands.

Testing

The hook talks to the SDK exclusively through useJunjo(). Render the consumer inside a JunjoProvider and either point the underlying Junjo at a fake server or stub the SDK methods directly:

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.groups, {
  get: vi.fn().mockResolvedValue(testGroup),
  subscribe: vi.fn().mockResolvedValue({ close: vi.fn() }),
});
Object.assign(client.members, {
  list: vi.fn().mockResolvedValue({ items: [memberA], nextCursor: null }),
});