useCan
Boolean wrapper around the SDK’s junjo.can(userId, groupId, permission). Returns true if the permission resolves allowed, false if denied, and undefined while the request is in flight (or if the request errored).
import { useCan } from "@junjo.io/react";
function InviteButton({ userId, groupId }: { userId: UserId; groupId: GroupId }) {
const canInvite = useCan(userId, groupId, "invite_member");
if (canInvite !== true) return null;
return <button type="button">Invite member</button>;
}Signature
function useCan(
userId: UserId,
groupId: GroupId,
permission: PermissionKey,
opts?: { inherit?: boolean },
): boolean | undefined;| Return | Meaning |
|---|---|
undefined | The request is loading, or the underlying call rejected. Treat as “not yet allowed”: render the protected UI as if denied. |
true | The user has the permission in this group. |
false | The user does not have the permission. |
Caching
The hook reads from a shared cache scoped to the nearest JunjoProvider. Multiple components calling useCan with the same (userId, groupId, permission) tuple deduplicate to a single network request, and once the result lands, every consumer of that tuple sees it instantly.
- Successful results are cached for the lifetime of the provider (or until the cache is invalidated). The first mount fires
junjo.can(...); subsequent mounts of the same key read the cached value synchronously. - Errors are not cached. A rejected
junjo.can(...)leaves the slot empty, so the next mount ofuseCanretries. The current mount stays atundefineduntil something causes it to remount or until the cache is invalidated. - Cache scope is per provider instance. Two
<JunjoProvider client={A}>and<JunjoProvider client={B}>trees do not share entries. Re-mounting the provider (or passing a differentclient) creates a fresh cache.
The cache has no TTL and no event-driven invalidation. The server’s permissions/check endpoint runs its own 60-second per-process cache that invalidates on role / permission mutations, but that invalidation does not propagate to the React cache. If a permission can flip mid-session, you currently need to remount the consuming component or wait for the next provider mount. A reactive invalidation layer is a post-V1 idea and would be transparent when it lands.
Errors
The hook never throws to the caller. A junjo.can(...) rejection (network error, invalid API key, server 500) leaves useCan returning undefined and lets the inflight slot release so a future mount retries.
If the hook is called outside a <JunjoProvider>, it throws synchronously with a descriptive message naming the missing provider.
Argument changes
Changing any of userId, groupId, or permission between renders re-keys the cache lookup. The hook fires a fresh junjo.can(...) for the new key (unless the cache already has it), and the return value flips to undefined until that resolves.
Composing with useGroup
The two hooks are independent: useCan does not require an active group subscription. Use them together when a UI needs both the group state and a permission gate:
function GuildAdmin({ userId, groupId }: { userId: UserId; groupId: GroupId }) {
const { group, members, loading } = useGroup(groupId);
const canKick = useCan(userId, groupId, "kick_member");
if (loading || !group) return null;
return (
<ul>
{members.map((m) => (
<li key={m.id}>
{m.userId} {canKick === true ? <KickButton memberId={m.id} /> : null}
</li>
))}
</ul>
);
}Inherited checks
Pass { inherit: true } to resolve against the group’s parents as well, nearest first, stopping at the first group that decides. Use it when authority lives on an ancestor: a “global admin” that exists only as a role on a root group is invisible to a direct check on a child.
const canEdit = useCan(userId, campaignId, "campaign.edit", { inherit: true });Inherited and direct answers are cached under separate keys, so the two spellings of the same triple do not overwrite each other. useInvalidatePermissions().invalidate(...) takes a matching fourth inherit argument; invalidateUser, invalidateGroup, and invalidateAll sweep both.
See Inherited checks for the resolution rules.
useCanMany
Gating a nav bar on six keys with useCan costs six requests. useCanMany answers a whole set in one, through the batch route, and returns the answers positionally.
import { useCanMany } from "@junjo.io/react";
function ShopList({ userId, shops }: { userId: UserId; shops: Group[] }) {
const allowed = useCanMany(
shops.map((shop) => ({ userId, groupId: shop.id, permission: "shop.view" })),
);
return (
<ul>
{shops.map((shop, i) => (allowed[i] === true ? <li key={shop.id}>{shop.name}</li> : null))}
</ul>
);
}function useCanMany(
checks: Array<{ userId: UserId; groupId: GroupId; permission: PermissionKey }>,
opts?: { inherit?: boolean },
): readonly (boolean | undefined)[];checksis read by value: a fresh array literal every render is fine and does not refetch. The hook keys on the tuple contents, not the array identity.- Entries already answered by
useCan(or a previoususeCanMany) are served from the shared cache, and only the remainder is sent. Invalidation reaches both hooks. - An entry is
undefinedwhile loading, and staysundefinedif the batch rejects. The hook never throws to the consumer. - An empty array makes no request.
- The returned array keeps a stable reference until a value actually changes, which is what keeps
useSyncExternalStorefrom re-rendering in a loop.
Testing
The hook talks to the SDK exclusively through useJunjo(). Stub junjo.can 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, { can: vi.fn().mockResolvedValue(true) });useCanMany goes through junjo.checkBatch, which returns full PermissionCheckResult objects rather than booleans:
Object.assign(client, {
checkBatch: vi.fn().mockResolvedValue([{ allowed: true, source: "role" }]),
});