useMembers
Returns the paginated roster for a group plus a live event subscription that keeps it in sync with member.joined, member.left, role.changed, member.banned, and member.unbanned events. The subscription rides the provider’s shared group event stream, so it adds no connection cost next to the other group hooks.
import { useMembers } from "@junjo.io/react";
function Roster({ groupId }: { groupId: GroupId }) {
const { members, loading, error, hasMore, fetchMore, loadingMore } = useMembers(groupId);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{members.map((m) => (
<li key={m.id}>{m.userId}</li>
))}
{hasMore ? (
<li>
<button type="button" onClick={fetchMore} disabled={loadingMore}>
{loadingMore ? "Loading more..." : "Load more"}
</button>
</li>
) : null}
</ul>
);
}Signature
function useMembers(groupId: GroupId, opts?: UseMembersOptions): UseMembersResult;
interface UseMembersOptions {
status?: MemberStatus | "all";
limit?: number;
}
interface UseMembersResult {
members: Member[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
error: Error | null;
refetch: () => Promise<void>;
fetchMore: () => Promise<void>;
applyOptimistic: (updater: (prev: Member[]) => Member[]) => () => void;
}| Field | Meaning |
|---|---|
members | The current roster, filtered by status. Server order on the initial page; new pages append at the end; live events insert / remove in place. |
loading | true from mount until the first members.list response (success or error). |
loadingMore | true while a fetchMore request is in flight. Becomes false again once it resolves or errors. |
hasMore | true when the last page returned a non-null nextCursor. Because the status filter runs server-side, this describes the filtered stream: hasMore: false means every member matching status has been loaded. |
error | The most recent error: a fetch error, a fetchMore error, or a streaming error. Stays set until the next refetch clears it. |
refetch | Resets state and re-runs the first page, discarding the cursor. Returns a Promise that resolves when the new page lands. |
fetchMore | Loads the next page using the stored cursor and appends new entries. No-op when hasMore is false or another fetchMore is already in flight. |
applyOptimistic | Applies an updater to the local members array immediately and returns a rollback closure that restores the pre-update snapshot. See Optimistic updates below. |
Status filter
The status option defaults to "active", which is the dominant case (a roster panel that should not show ex-members). The valid values are "active", "invited", "left", "kicked", "banned", or "all".
The filter is applied server-side. For any status other than "all", the hook forwards it as status: [status] on junjo.members.list, which the SDK sends as the ?status= query param on GET /v1/groups/:id/members (see API reference). Every page therefore contains only matching rows, and hasMore and the stored cursor describe the filtered stream: no bandwidth is spent on rows the UI would discard, and a group with one active member out of 1000 historical rows surfaces that member on the first page. For "all", the option is omitted and the server returns every status.
Live SSE events are the one place the filter still runs client-side: the shared stream delivers events for members of any status, so the hook re-checks each incoming member against status before inserting, updating, or dropping a row (see Live updates).
Changing the status between renders triggers a refetch (the cursor resets and a fresh first page lands with the new query) but does NOT re-open the SSE subscription.
Pagination
Pagination is cursor-based. The hook calls junjo.members.list(groupId, opts) for each page, where the first call carries { status?: [status], limit?: number } (no cursor) and subsequent fetchMore calls carry { status?: [status], cursor: <last-page-cursor>, limit?: number }. The server’s response shape is { items: Member[]; 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 userId when appending, so a member already present in the current snapshot (e.g., added via a member.joined event between page boundaries) will not appear twice.
Live updates
On mount, the hook attaches to the group’s shared event stream. Events carry members of any status, so each one is matched against the status filter client-side before it touches state; live updates therefore respect the filter even though the fetch path filters server-side. Five event types modify state:
| Event | Behavior |
|---|---|
member.joined | If the member matches the current filter, append at the end (or replace in-place when the same userId is already present, idempotent on dedupe). If the filter excludes them, ignore. |
member.left | Remove the member from the visible roster regardless of filter. For non-active filters, call refetch if you need to track lifecycle status precisely; V1 does not synthesize the post-left member shape from event data. |
role.changed | Update the member’s roles in place: remove anything in event.removed, then append anything in event.added that is not already present. |
member.banned | If the user is in the visible roster, flip the row to status: "banned" with bannedUntil: event.bannedUntil, then apply the filter: on an "active" (or other non-matching) list the row drops out live; on a "banned" or "all" list it stays, updated in place. |
member.unbanned | If the user is in the visible roster, flip the row to status: "left" (the server’s unban handler sets "left", not "active": an unbanned member has to rejoin) with bannedUntil: null, then apply the filter the same way. |
Ban events update rows already present; they carry no member snapshot, so they cannot insert rows. A "banned"-filtered list learns about newly banned members it never loaded only on the next refetch.
Other events (role.created, role.deleted, permission.granted, permission.revoked, member.invited, group.updated, group.deleted, 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.
Errors
The hook never throws; errors land in result.error. Three sources:
- Initial fetch error: a
JunjoError(or other) from the firstmembers.listcall. The hook stays atloading: falsewithmembers: []. fetchMoreerror: appended toerrorwhilememberskeeps the existing snapshot.loadingMoreflips back tofalse. CallingfetchMoreagain 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 withisStreamClosedError). The current snapshot stays intact; onlyerrorflips. The hook does NOT auto-reconnect; remount or changegroupIdto resubscribe (refetchrefreshes the snapshot but does not reopen the stream). See Stream teardown for the full contract.
If useMembers 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 roster before the server confirms. It runs updater(state.members) and replaces members 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 { useJunjo, useMembers, useMutation } from "@junjo.io/react";
function KickButton({ groupId, userId }: { groupId: GroupId; userId: UserId }) {
const junjo = useJunjo();
const { applyOptimistic } = useMembers(groupId);
const { mutate, isPending } = useMutation<void, Error, void, { rollback: () => void }>({
mutationFn: () => junjo.members.kick(groupId, userId),
onMutate: () => ({
rollback: applyOptimistic((prev) => prev.filter((m) => m.userId !== userId)),
}),
onError: (_err, _vars, ctx) => ctx?.rollback(),
});
return <button type="button" onClick={() => mutate()} disabled={isPending}>Kick</button>;
}The same shape covers role assignment:
function AssignRoleButton({
groupId,
userId,
roleId,
}: {
groupId: GroupId;
userId: UserId;
roleId: RoleId;
}) {
const junjo = useJunjo();
const { applyOptimistic } = useMembers(groupId);
const { mutate } = useMutation<Member, Error, void, { rollback: () => void }>({
mutationFn: () => junjo.members.assignRole(groupId, userId, roleId),
onMutate: () => ({
rollback: applyOptimistic((prev) =>
prev.map((m) =>
m.userId === userId && !m.roles.includes(roleId)
? { ...m, roles: [...m.roles, roleId] }
: m,
),
),
}),
onError: (_err, _vars, ctx) => ctx?.rollback(),
});
return <button type="button" onClick={() => mutate()}>Assign</button>;
}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 a member.left for the same user; the reducer’s member.left handler is idempotent, so the optimistic removal is preserved without flicker. A successful assignRole emits a role.changed whose added array is filtered against the existing roles, so the optimistic role addition is preserved.
Rollback restores the pre-update snapshot exactly
The rollback closure stores the members array as it was when applyOptimistic was called, and restores it verbatim. If unrelated SSE events arrived between the optimistic update and the rollback, those changes are dropped on rollback. In practice this is rare (the mutation window is short) and the next refetch reconciles. 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. This matches React Query’s mutation rollback behavior. The mitigation is the same: rollbacks are rare on short mutation windows, and refetch reconciles divergence.
applyOptimistic does not call the SDK
The hook only mutates local state; the network request is whatever you put in mutationFn. This is by design: any mutation (kick, invite, role assignment, custom server route) can wire optimistic UI through the same primitive without the hook needing per-method support.
Composing with useGroup
The two hooks share one SSE stream per (client, groupId) through the provider’s subscription hub. A screen rendering both (or adding useInvitations and useRoles on top) 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.members.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.members, { list: vi.fn().mockResolvedValue({ items: [], nextCursor: null }) });
Object.assign(client.groups, {
subscribe: vi.fn().mockResolvedValue({ close: vi.fn() }),
});