ReactuseCan + useCanMany

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;
ReturnMeaning
undefinedThe request is loading, or the underlying call rejected. Treat as “not yet allowed”: render the protected UI as if denied.
trueThe user has the permission in this group.
falseThe 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 of useCan retries. The current mount stays at undefined until 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 different client) 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)[];
  • checks is 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 previous useCanMany) are served from the shared cache, and only the remainder is sent. Invalidation reaches both hooks.
  • An entry is undefined while loading, and stays undefined if 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 useSyncExternalStore from 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" }]),
});