
In my last article, How to Implement Role-Based Access Control (RBAC) in React.js App, I walked through building route guards — the <ProtectedRoute> wrapper that checks a user's role before letting them land on /admin or /billing. That's step one, and if you haven't done it yet, go do it first. Everything below assumes roles and permissions are already flowing through your app.
But route guards only solve half the problem. A route guard stops someone from navigating to a page they shouldn’t see. It does nothing about the “Delete Project” button sitting inside a page that a viewer and an admin both have legitimate access to — it doesn't know or care that one of those users shouldn't be able to click it.
That’s the gap this article closes: permission-aware UI at the component level — buttons that don’t render for users who can’t use them, panels that swap between “full” and “read-only” views, and a pattern that scales past one-off if (user.role === 'admin') checks scattered across your codebase.
Where Route-Guard RBAC Stops Being Enough

Picture a fairly ordinary dashboard route: /projects/:id. Every authenticated user — viewer, editor, and admin — is allowed on that page. Your route guard is happy. The problem starts inside the page itself:
// ProjectPage.tsx — the naive version
function ProjectPage() {
const { user } = useAuth();
return (
<div>
<ProjectHeader />
<ProjectTimeline />
{/* Scattered, untyped, easy to get wrong */}
{user.role === 'admin' && <DeleteProjectButton />}
{(user.role === 'admin' || user.role === 'editor') && <EditSettingsButton />}
{user.permissions.includes('export:data') && <ExportButton />}
</div>
);
}This works, technically, but it rots fast:
- Role and permission checks mix inconsistently. Some compare role, others check a permissions array — nobody agrees which is the source of truth.
- The logic isn’t reusable. The same “can this user edit settings” check gets re-written, slightly differently, in three other components.
- It doesn’t react to role changes. If a role changes mid-session, none of these inline checks re-evaluate unless the whole page reloads.
- It’s untestable in isolation. You can’t assert “the delete button doesn’t render for a viewer” without mounting the whole page and mocking useAuth.
What you want is the same mental model as your route guards — a single source of truth for “can this user do X” — pushed down to the component level: a hook that answers permission questions, a component that renders based on the answer, and a context that keeps both in sync when the role changes.
Designing a useHasPermission Hook
Start by defining what a permission even is in your app. I like a flat string convention — resource:action — because it's easy to reason about, easy to store in a JWT claim, and easy to check with includes.
// types/permissions.ts
export type Permission =
| 'project:view'
| 'project:edit'
| 'project:delete'
| 'project:export'
| 'billing:view'
| 'billing:edit'
| 'user:invite'
| 'user:remove';
export type Role = 'viewer' | 'editor' | 'admin' | 'owner';
// A static map from role -> permissions. In a real app this often comes
// from your backend (e.g. embedded in the JWT or fetched on login), but
// keeping a client-side fallback map is useful for tests and defaults.
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
viewer: ['project:view'],
editor: ['project:view', 'project:edit', 'project:export'],
admin: ['project:view', 'project:edit', 'project:delete', 'project:export', 'user:invite'],
owner: [
'project:view', 'project:edit', 'project:delete', 'project:export',
'user:invite', 'user:remove', 'billing:view', 'billing:edit',
],
};
Now the auth context. This is the same one your route guards likely already read from — the difference is we’re going to expose a permissions set derived from the current user, not just a role string.
// context/AuthContext.tsx
import { createContext, useContext, useMemo, useState, useCallback } from 'react';
import { Permission, Role, ROLE_PERMISSIONS } from '../types/permissions';
interface AuthUser {
id: string;
name: string;
role: Role;
// Optional: explicit per-user overrides on top of the role's defaults,
// e.g. a viewer who was individually granted export rights.
extraPermissions?: Permission[];
}interface AuthContextValue {
user: AuthUser | null;
permissions: Set<Permission>;
setUser: (user: AuthUser | null) => void;
}// Exported so test utilities can provide a context value directly
// without going through the real login flow.
export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);// Recompute permissions any time the user object changes — role change,
// login, logout, or a token refresh that swaps in a new user.
const permissions = useMemo(() => {
if (!user) return new Set<Permission>();
const base = ROLE_PERMISSIONS[user.role] ?? [];
return new Set<Permission>([...base, ...(user.extraPermissions ?? [])]);
}, [user]);
const value = useMemo(
() => ({ user, permissions, setUser }),
[user, permissions],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}The useMemo on permissions is keyed on user, so any time setUser fires with a new role, the whole set recalculates and every consumer re-renders with fresh data — that detail matters later when we talk about role changes.
The hook itself is almost trivially thin, which is the point: logic lives in the context, the hook is just an ergonomic accessor.
// hooks/useHasPermission.ts
import { useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { Permission } from '../types/permissions';
export function useHasPermission() {
const { permissions } = useAuth();// useCallback so components that pass this function down as a prop
// don't cause unnecessary re-renders of memoized children.
const hasPermission = useCallback(
(required: Permission | Permission[]): boolean => {
if (Array.isArray(required)) {
// "any of" semantics — swap to .every() if you need "all of"
return required.some((p) => permissions.has(p));
}
return permissions.has(required);
},
[permissions],
);
return hasPermission;
}
Usage is a single, type-checked line — if (!hasPermission('project:delete')) return null — already a big improvement over scattered role === 'admin' checks. But it gets awkward once you need fallback content or want your JSX to read declaratively, so we can do better with a dedicated component.
Building a <Can> Component for Conditional Rendering
Enter <Can> — a component modeled after libraries like CASL, but purpose-built and dependency-free.
// components/Can.tsx
import { ReactNode } from 'react';
import { useHasPermission } from '../hooks/useHasPermission';
import { Permission } from '../types/permissions';
interface CanProps {
/** One permission, or a list checked with OR semantics. */
permission: Permission | Permission[];
/** Require ALL of the listed permissions instead of ANY. */
requireAll?: boolean;
/** Static children, or a render-prop that receives the allowed flag. */
children: ReactNode | ((allowed: boolean) => ReactNode);
/** What to render when the check fails. Defaults to nothing. */
fallback?: ReactNode;
}export function Can({ permission, requireAll = false, children, fallback = null }: CanProps) {
const hasPermission = useHasPermission();const allowed = Array.isArray(permission)
? requireAll
? permission.every((p) => hasPermission(p))
: permission.some((p) => hasPermission(p))
: hasPermission(permission);
// Children-as-function pattern: lets a component get access to the
// boolean directly, useful when you want to disable rather than hide
// (see the "hiding vs. disabling" pitfall further down).
if (typeof children === 'function') {
return <>{children(allowed)}</>;
}
return <>{allowed ? children : fallback}</>;
}In practice, this cleans up the project page from earlier considerably:
// ProjectPage.tsx — with <Can>
function ProjectPage() {
return (
<div>
<ProjectHeader />
<ProjectTimeline />
<Can permission="project:delete">
<DeleteProjectButton />
</Can>
<Can permission={['project:edit']}>
<EditSettingsButton />
</Can> <Can permission="project:export" fallback={<UpgradePrompt feature="export" />}>
<ExportButton />
</Can> {/* Children-as-function: keep the button visible but disabled,
with a tooltip explaining why — better UX than hiding it. */}
<Can permission="user:invite">
{(allowed) => (
<button disabled={!allowed} title={!allowed ? 'Admins only' : undefined}>
Invite Teammate
</button>
)}
</Can>
</div>
);
}This reads the way a product spec reads: “if the user can delete a project, show the delete button.” No role comparisons in sight, and the permission model lives in one place, so a policy change is a one-line diff instead of a grep-and-replace.
Handling Role Changes Without a Full Page Reload

Roles aren’t static. An admin might demote a teammate in real time, or a JWT refresh might bring in an updated claim set. If permission checks read from stale state, the UI keeps showing an admin-only button to a user who was just demoted, and clicking it produces a confusing 403.
Because the permission set is centralized in AuthContext via useMemo(() => ..., [user]), any update to user automatically cascades to every <Can> and useHasPermission() consumer in the tree — no manual invalidation needed. The remaining piece is making sure setUser actually gets called when the role changes, typically via a server push or a token refresh:
// hooks/useRoleSync.ts
import { useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { Role } from '../types/permissions';
interface RoleChangedEvent {
type: 'ROLE_CHANGED';
role: Role;
}/**
* Subscribes to a live channel for role updates and pushes them into
* AuthContext, so every <Can> / useHasPermission consumer re-evaluates
* immediately — no reload, no stale "admin" button lingering on screen.
*/
export function useRoleSync(socketUrl: string) {
const { user, setUser } = useAuth();
useEffect(() => {
if (!user) return;const socket = new WebSocket(socketUrl);
socket.onmessage = (event) => {
const data: RoleChangedEvent = JSON.parse(event.data);
if (data.type === 'ROLE_CHANGED') {
// Replacing the user object triggers AuthProvider's useMemo,
// which recomputes `permissions`, which re-renders every
// consumer that reads it. This is the whole mechanism —
// no event bus, no manual cache-busting required.
setUser((prev) => (prev ? { ...prev, role: data.role } : prev));
}
};return () => socket.close();
}, [user?.id, socketUrl, setUser]);
}
Without a live socket, the same idea applies to a token refresh interceptor — decode the new JWT and call setUser with the merged result. The architectural point: exactly one place holds role/permission state (AuthContext), and every UI decision derives from it reactively. Never let a component cache its own copy of "am I an admin" in local state.
Combining RBAC with Feature Flags for Gradual Rollouts
Permissions answer “is this user allowed to do this, ever.” Feature flags answer “is this capability available right now, to this cohort.” You’ll often want both — only admins can use the new bulk-export tool, and even among admins it’s enabled for just 20% of accounts while you validate it in production. Keep the two systems separate and compose them, rather than baking flag logic into the permission map:
// hooks/useFeatureFlag.ts
import { useContext, createContext, useMemo } from 'react';
interface FeatureFlags {
[flagKey: string]: boolean;
}const FeatureFlagContext = createContext<FeatureFlags>({});export function FeatureFlagProvider({
flags,
children,
}: {
flags: FeatureFlags;
children: React.ReactNode;
}) {
return (
<FeatureFlagContext.Provider value={flags}>{children}</FeatureFlagContext.Provider>
);
}export function useFeatureFlag(key: string): boolean {
const flags = useContext(FeatureFlagContext);
return flags[key] ?? false;
}Then a small combinator component that reuses <Can> instead of reinventing it:
// components/CanWithFlag.tsx
import { ReactNode } from 'react';
import { Can } from './Can';
import { useFeatureFlag } from '../hooks/useFeatureFlag';
import { Permission } from '../types/permissions';
interface CanWithFlagProps {
permission: Permission | Permission[];
flag: string;
children: ReactNode;
fallback?: ReactNode;
}/**
* Gate on BOTH axes: the user must be permitted (RBAC) AND the feature
* must be enabled for them (flag). Keeping these as separate systems
* means you can roll a feature out to admins gradually without ever
* touching the permission map.
*/
export function CanWithFlag({ permission, flag, children, fallback = null }: CanWithFlagProps) {
const flagEnabled = useFeatureFlag(flag);
if (!flagEnabled) return <>{fallback}</>;return (
<Can permission={permission} fallback={fallback}>
{children}
</Can>
);
}
Usage:
<CanWithFlag permission="project:export" flag="bulk-export-v2">
<BulkExportButton />
</CanWithFlag>
This gives you a rollout playbook that never touches the RBAC model: flip bulk-export-v2 on for internal accounts, then a percentage cohort, then everyone, while project:export stays exactly as strict as it's always been. Once the flag holds at 100% for a release or two, delete it and drop back to a plain <Can>.
Testing Permission-Gated UI
Because permission logic is centralized in a hook and a context, testing it doesn’t require mounting your whole app. Export a TestAuthProvider that takes a user directly and vary the role per test:
// test-utils/TestAuthProvider.tsx
import { useMemo } from 'react';
import { AuthContext } from '../context/AuthContext';
import { Permission, ROLE_PERMISSIONS, Role } from '../types/permissions';
interface TestUser {
id: string;
role: Role;
extraPermissions?: Permission[];
}export function TestAuthProvider({
user,
children,
}: {
user: TestUser | null;
children: React.ReactNode;
}) {
const permissions = useMemo(() => {
if (!user) return new Set<Permission>();
return new Set([...(ROLE_PERMISSIONS[user.role] ?? []), ...(user.extraPermissions ?? [])]);
}, [user]);return (
<AuthContext.Provider value={{ user, permissions, setUser: () => {} }}>
{children}
</AuthContext.Provider>
);
}
// components/__tests__/Can.test.tsx
import { render, screen } from '@testing-library/react';
import { TestAuthProvider } from '../../test-utils/TestAuthProvider';
import { Can } from '../Can';
describe('<Can>', () => {
it('renders children when the user has the required permission', () => {
render(
<TestAuthProvider user={{ id: 'u1', role: 'admin' }}>
<Can permission="project:delete">
<button>Delete Project</button>
</Can>
</TestAuthProvider>,
); expect(screen.getByText('Delete Project')).toBeInTheDocument();
}); it('does not render children when the user lacks the permission', () => {
render(
<TestAuthProvider user={{ id: 'u1', role: 'viewer' }}>
<Can permission="project:delete">
<button>Delete Project</button>
</Can>
</TestAuthProvider>,
); expect(screen.queryByText('Delete Project')).not.toBeInTheDocument();
}); it('passes the allowed flag through the children-as-function pattern', () => {
render(
<TestAuthProvider user={{ id: 'u1', role: 'viewer' }}>
<Can permission="user:invite">
{(allowed) => <button disabled={!allowed}>Invite</button>}
</Can>
</TestAuthProvider>,
); expect(screen.getByText('Invite')).toBeDisabled();
});
});The goal here is to assert on rendered output for each role, not on internal hook return values — that’s what actually protects you when someone refactors the permission map later.
Common Pitfalls

Hiding vs. Disabling
Removing an element from the DOM and disabling it are different product decisions, and conflating them causes real confusion. If a viewer has no plausible path to project:delete, hiding the button is correct — a disabled delete button is just a dead end. But if the permission is something the user could earn (an upgrade, an admin grant, a pending approval), disabling with a tooltip or a fallback upsell is almost always better UX than making the feature invisible — invisible features don't get requested, they just get missed. Reach for the children-as-function form of <Can> for this case rather than a second component.
Client-Side-Only Checks
This is the one that matters most, so I’ll say it plainly: everything in this article is UX, not security. <Can>, useHasPermission, the whole permission map — it all lives in a JavaScript bundle anyone can open in DevTools and patch or bypass. Hiding the "Delete Project" button does not stop someone from firing DELETE /api/projects/123 directly with curl and a stolen token.
Every mutation your UI gates must be re-checked by your API using the same permission data, independent of what the client claims. Treat client-side RBAC as good UX — don’t show people controls they can’t use — and server-side authorization as the actual security boundary. If you can only build one, build the server-side check; the UI polish is worthless without it, but the reverse is catastrophic.
Wrapping Up
You now have a full stack for role-aware UI: a useHasPermission hook as the single source of truth, a <Can> component that keeps your JSX declarative, context wiring that propagates role changes without a reload, a composable pattern for layering feature flags on top of RBAC, and a test setup that asserts on rendered output per role instead of trusting the logic by inspection.
Two concrete next steps if you’re bringing this into an existing codebase. First, audit for user.role === comparisons scattered through components and replace them with <Can> one screen at a time — no big-bang migration required. Second, and more important: for every control you gate client-side, confirm the corresponding API endpoint enforces the same permission server-side, independent of the client. That second step is the one that's easy to skip — client-side checks make your app pleasant to use; server-side checks are what make it safe.
