# Building Dynamic RBAC in React 19: From Permission Strings to Component-Level Access Control

> Source: <https://dev.to/uaslimcreate/building-dynamic-rbac-in-react-19-from-permission-strings-to-component-level-access-control-14j9>
> Published: 2026-05-23 09:22:46+00:00

# Building Dynamic RBAC in React 19: From Permission Strings to Component-Level Access Control

String-based permission checks scattered across your React codebase are a maintenance nightmare. I know because I shipped CitizenApp with that anti-pattern, and it nearly bit me when we added our fifth AI feature.

The problem? Permissions were hardcoded in components. When the marketing team wanted to trial a feature with select customers, I had to grep through half the codebase, find every `if (user.role === 'admin')`

check, and create some Frankenstein conditional. Worse, there was no single source of truth for what "feature_x_access" actually meant across our tenant hierarchy.

This post shows you how to build a type-safe, composable RBAC layer that lives *outside* your components. Your UI asks "can I do X?" and the permission engine answers. Clean separation. Testable. Scales.

## The Core Philosophy: Permissions Are Data, Not Logic

Don't embed permissions in your component tree. Treat permissions as **configuration** that your components consume. This single shift unlocks everything.

Here's what bad looks like:

```
// ❌ Don't do this
export function AIFeatureCard() {
  const { user } = useAuth();

  if (user.role !== 'admin' && user.role !== 'premium_subscriber') {
    return null;
  }

  return <div>AI Feature</div>;
}
```

Problems:

- Role names are magic strings
- Permission logic is fragmented across 20 components
- Adding a new role? Find and update every check
- No way to test permissions without rendering components
- No audit trail of what permission was checked where

Here's what good looks like:

``` js
// ✅ Do this
const canAccessAIFeature = await checkPermission('features:ai:access', {
  userId,
  tenantId,
});

if (canAccessAIFeature) {
  return <AIFeatureCard />;
}
```

Now permissions are data. You can log them, cache them, test them, audit them.

## Type-Safe Permission Definitions

Start with TypeScript. Define every permission your app has as a const object:

``` js
// permissions.ts
export const PERMISSIONS = {
  // Organization management
  'org:create': 'Create organization',
  'org:update': 'Update organization settings',
  'org:delete': 'Delete organization',
  'org:invite_members': 'Invite team members',

  // AI features (your feature gates)
  'features:ai:access': 'Access any AI feature',
  'features:ai:documents': 'Use document analysis',
  'features:ai:workflows': 'Create automation workflows',
  'features:ai:exports': 'Export AI-generated content',

  // Admin
  'admin:billing': 'Manage billing',
  'admin:audit_logs': 'View audit logs',
} as const;

// Extract type: 'org:create' | 'org:update' | ...
export type PermissionKey = keyof typeof PERMISSIONS;
```

This gives you autocomplete and catches typos at compile time. No more permission strings as magic text.

## Building the Permission Engine

Your permission engine lives on the backend and is queried from React. Here's the FastAPI service:

``` python
# permissions.py
from enum import Enum
from typing import Set
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session

class RoleType(str, Enum):
    OWNER = "owner"
    ADMIN = "admin"
    MEMBER = "member"
    GUEST = "guest"

# Define role -> permissions mapping
ROLE_PERMISSIONS: dict[RoleType, Set[str]] = {
    RoleType.OWNER: {
        "org:create", "org:update", "org:delete", "org:invite_members",
        "features:ai:access", "features:ai:documents", "features:ai:workflows",
        "features:ai:exports", "admin:billing", "admin:audit_logs",
    },
    RoleType.ADMIN: {
        "org:update", "org:invite_members",
        "features:ai:access", "features:ai:documents", "features:ai:workflows",
        "features:ai:exports", "admin:audit_logs",
    },
    RoleType.MEMBER: {
        "features:ai:access", "features:ai:documents", "features:ai:workflows",
    },
    RoleType.GUEST: {
        "features:ai:documents",  # Read-only
    },
}

# Handle permission inheritance for multi-tenant
class PermissionResolver:
    def __init__(self, db: Session):
        self.db = db

    async def get_user_permissions(
        self, user_id: str, tenant_id: str
    ) -> Set[str]:
        """Resolve all permissions for a user in a tenant."""
        # Query user role in this tenant
        membership = self.db.query(TenantMembership).filter(
            TenantMembership.user_id == user_id,
            TenantMembership.tenant_id == tenant_id,
        ).first()

        if not membership:
            return set()

        # Start with their direct role permissions
        permissions = ROLE_PERMISSIONS.get(membership.role, set()).copy()

        # Add custom permissions (if you've granted individual perms)
        custom = self.db.query(UserPermission).filter(
            UserPermission.user_id == user_id,
            UserPermission.tenant_id == tenant_id,
        ).all()

        for perm in custom:
            permissions.add(perm.permission_key)

        return permissions

    async def can_perform(
        self, user_id: str, tenant_id: str, permission: str
    ) -> bool:
        """Check if user can perform an action."""
        permissions = await self.get_user_permissions(user_id, tenant_id)
        return permission in permissions

# Expose as endpoint
@app.post("/api/permissions/check")
async def check_permission(
    request: CheckPermissionRequest,  # {permission, tenant_id}
    user_id: str = Depends(get_current_user_id),
    db: Session = Depends(get_db),
) -> dict[str, bool]:
    resolver = PermissionResolver(db)
    allowed = await resolver.can_perform(
        user_id, request.tenant_id, request.permission
    )
    return {"allowed": allowed}
```

Notice: **No logic in components.** All rules live in `ROLE_PERMISSIONS`

and the database. When you add a feature, you update `ROLE_PERMISSIONS`

once. Done.

## React Hook: usePermission

Now the React side. Create a hook that queries your permission endpoint:

``` js
// usePermission.ts
import { useAuth } from './useAuth';
import { PermissionKey, PERMISSIONS } from './permissions';

interface UsePermissionOptions {
  cacheSeconds?: number;
}

export function usePermission(
  permissionKey: PermissionKey,
  options: UsePermissionOptions = {}
) {
  const { user, tenantId } = useAuth();
  const [allowed, setAllowed] = React.useState<boolean | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<Error | null>(null);

  React.useEffect(() => {
    if (!user || !tenantId) {
      setLoading(false);
      setAllowed(false);
      return;
    }

    const checkPerm = async () => {
      try {
        const response = await fetch('/api/permissions/check', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            permission: permissionKey,
            tenant_id: tenantId,
          }),
        });

        if (!response.ok) throw new Error('Permission check failed');
        const data = await response.json();
        setAllowed(data.allowed);
      } catch (err) {
        setError(err as Error);
        setAllowed(false); // Fail closed
      } finally {
        setLoading(false);
      }
    };

    checkPerm();
  }, [user?.id, tenantId, permissionKey]);

  return { allowed, loading, error };
}
```

Usage in components:

```
// AIFeatureCard.tsx
export function AIFeatureCard() {
  const { allowed, loading } = usePermission('features:ai:access');

  if (loading) return <Skeleton />;
  if (!allowed) return null;

  return <div className="p-4 bg-blue-50">AI Feature</div>;
}
```

Clean. Testable. Type-safe.

## Caching: The Performance Multiplier

This burned me: I shipped this without caching, and every component requesting the same permission hammered the backend. Use React Query:

``` js
// usePermission.ts (improved)
import { useQuery } from '@tanstack/react-query';

export function usePermission(
  permissionKey: PermissionKey,
  options: UsePermissionOptions = {}
) {
  const { user, tenantId } = useAuth();
  const cacheSeconds = options.cacheSeconds ?? 300; // 5 min default

  return useQuery({
    queryKey: ['permission', tenantId, permissionKey],
    queryFn: async () => {
      const response = await fetch('/api/permissions/check', {
        method: 'POST',
        body: JSON.stringify({
          permission: permissionKey,
          tenant_id: tenantId,
        }),
      });
      const data = await response.json();
      return data.allowed;
    },
    staleTime: cacheSeconds * 1000,
    enabled: !!user && !!tenantId,
  });
}
```

Now the second component asking for `features:ai:access`

hits the cache, not your backend.
