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;
}| Field | Type | Notes |
|---|---|---|
group | Group | null | The group on success. null while loading, after a 404, or after a group.deleted event arrives on the live stream. |
members | Member[] | 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. |
loading | boolean | true 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. |
error | Error | null | The 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. |
membersHasMore | boolean | true 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) => () => void | Applies 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)andjunjo.members.list(groupId, { status: ["active"] })in parallel. The active-status filter runs server-side, so the member cursor andmembersHasMoredescribe 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.
fetchMoreMembersfetches the next page with the stored cursor (stillstatus: ["active"]) and appends it, deduplicated byuserId;membersHasMoremirrorsnextCursor !== null. Page requests are generation-guarded: arefetchorgroupIdchange invalidates any in-flight page so a stale response cannot land on the new state, and a second concurrentfetchMoreMembersreturns 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
groupIdchange, 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
groupIdchanges mid-flight orrefetchis called while another fetch is pending. - Snapshot durability. A streaming error or server close sets
errorbut leavesgroupandmembersunchanged so the UI can keep rendering the last-known state.refetchrefreshes the snapshot; remount or changegroupIdto 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
refetchfor an authoritative snapshot.
Event handling
| Event | Effect on state |
|---|---|
member.joined | Appends event.member to members. If a member with the same userId already exists, replaces that entry in place. |
member.left | Removes any member with the matching userId. |
role.changed | Finds the member with event.userId; replaces their roles with (roles - removed) + added. Members not currently in the roster are ignored. |
member.banned / member.unbanned | Removes 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.updated | Replaces group with event.group. |
group.deleted | Sets 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 types | Ignored. 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.getormembers.listrejects ->errorset,loading->false. The priorgroup/memberssnapshot is left intact (relevant on a failedrefetch; on mount it is simply empty).JunjoErrorinstances pass through unchanged.- The SSE handshake fails (typically
permission_deniedor a network error) or the stream drops mid-flight ->errorset with the underlying error;groupandmembersare not touched. - The server closes the stream cleanly (a deploy, a proxy idle timeout) ->
errorset toJunjoStreamClosedError(check withisStreamClosedError); 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 }),
});