@project-kessel/react-kessel-access-check
v0.5.0
Published
A React SDK for performing granular and bulk access checks against the Kessel access check service. This package provides a standardized way to verify user permissions for resources like workspaces, inventory groups, and other entities in your application
Downloads
5,841
Readme
@project-kessel/react-kessel-access-check
A React SDK for performing granular and bulk access checks against the Kessel access check service. This package provides a standardized way to verify user permissions for resources like workspaces, inventory groups, and other entities in your application. It also includes helpers for fetching workspace IDs from RBAC for use as resource IDs in access checks.
Table of Contents
- Installation
- Quick Start
- API Reference
- Usage Examples
- Configuration
- TypeScript Support
- Architecture
- Backend API Integration
- Best Practices
Installation
npm install @project-kessel/react-kessel-access-checkor
yarn add @project-kessel/react-kessel-access-checkor
pnpm add @project-kessel/react-kessel-access-checkQuick Start
import React from 'react';
import { AccessCheck, useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
// 1. Wrap your application with the provider
function App() {
return (
<AccessCheck.Provider
baseUrl="https://console.redhat.com"
apiPath="/api/kessel/v1beta2"
>
<YourApplication />
</AccessCheck.Provider>
);
}
// 2. Use the hook in your components
function WorkspaceView({ workspaceId }) {
const { data, loading, error } = useSelfAccessCheck({
relation: 'view',
resource: {
id: workspaceId,
type: 'workspace',
reporter: { type: 'rbac' }
}
});
if (loading) {
return <Spinner />;
}
if (!data?.allowed) {
return <div>You don't have permission to view this workspace.</div>;
}
return <div>Workspace Content...</div>;
}API Reference
AccessCheck.Provider
The main provider component that wraps your application and provides access check context to all child components.
Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| baseUrl | string | Yes | The base URL for the API server (e.g., https://console.redhat.com) |
| apiPath | string | Yes | The base path for the access check API endpoints (e.g., /api/kessel/v1beta2) |
| bulkCheckConfig | { bulkRequestLimit?: number } | No | Optional chunk size for bulk checks. If omitted, defaults to 1000. Set bulkRequestLimit to limit the number of items per bulk request |
| children | ReactNode | Yes | Child components that will have access to the access check hooks |
Example
<AccessCheck.Provider
baseUrl="https://console.redhat.com"
apiPath="/api/kessel/v1beta2"
>
<App />
</AccessCheck.Provider>useSelfAccessCheck
Hook for checking if the current user has the specified relation(s) to resource(s). Supports three overloads for different use cases.
Overload 1: Single Resource Check
Check if the user has a specific relation to a single resource.
Signature
function useSelfAccessCheck(params: {
relation: string;
resource: {
id: string;
type: string;
reporter: { type: string; instanceId?: string };
[key: string]: unknown;
};
}): {
data?: {
allowed: boolean;
resource: SelfAccessCheckResource;
};
loading: boolean;
error?: SelfAccessCheckError;
}Example
const { data, loading, error } = useSelfAccessCheck({
relation: 'delete',
resource: {
id: 'ws-123',
type: 'workspace',
reporter: { type: 'rbac' }
}
});
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return (
<Button disabled={!data?.allowed}>
Delete Workspace
</Button>
);Overload 2: Bulk Resource Check - Same Relation
Check the same relation across multiple resources.
Signature
function useSelfAccessCheck(params: {
relation: string;
resources: [
{ id: string; type: string; reporter: { type: string; instanceId?: string }; [key: string]: unknown },
...Array<{ id: string; type: string; reporter: { type: string; instanceId?: string }; [key: string]: unknown }>
];
options?: {
consistency?: {
minimizeLatency?: boolean;
atLeastAsFresh?: ConsistencyToken;
};
};
}): {
data?: Array<{
allowed: boolean;
resource: SelfAccessCheckResource;
relation: string;
error?: SelfAccessCheckError;
}>;
loading: boolean;
error?: SelfAccessCheckError;
consistencyToken?: ConsistencyToken;
}Example
const { data: checks, loading } = useSelfAccessCheck({
relation: 'delete',
resources: [
{ id: 'ws-1', type: 'workspace', reporter: { type: 'rbac' } },
{ id: 'ws-2', type: 'workspace', reporter: { type: 'rbac' } },
{ id: 'ws-3', type: 'workspace', reporter: { type: 'rbac' } }
]
});
const deletableWorkspaces = checks
?.filter(check => check.allowed)
.map(check => check.resource);Overload 3: Bulk Resource Check - Nested Relations
Check different relations on different resources in a single request.
Signature
function useSelfAccessCheck(params: {
resources: [
{ id: string; type: string; relation: string; reporter: { type: string; instanceId?: string }; [key: string]: unknown },
...Array<{ id: string; type: string; relation: string; reporter: { type: string; instanceId?: string }; [key: string]: unknown }>
];
options?: {
consistency?: {
minimizeLatency?: boolean;
atLeastAsFresh?: ConsistencyToken;
};
};
}): {
data?: Array<{
allowed: boolean;
resource: SelfAccessCheckResource;
relation: string;
error?: SelfAccessCheckError;
}>;
loading: boolean;
error?: SelfAccessCheckError;
consistencyToken?: ConsistencyToken;
}Example
const { data: checks, loading } = useSelfAccessCheck({
resources: [
{ id: 'ws-1', type: 'workspace', relation: 'delete', reporter: { type: 'rbac' } },
{ id: 'ws-2', type: 'workspace', relation: 'view', reporter: { type: 'rbac' } },
{ id: 'ws-3', type: 'workspace', relation: 'edit', reporter: { type: 'rbac' } }
]
});
// Find any errors in the bulk check
const errors = checks?.filter(check => check.error);fetchRootWorkspace / fetchDefaultWorkspace
Async helper functions for fetching workspace IDs from the RBAC API. These are useful when the resource you need to perform an access check against is a workspace, and you need its UUID.
These call GET /api/rbac/v2/workspaces/?type={root|default} and return the first matching workspace.
Follows the Kessel SDK client API spec for rbac.v2.
Signature
function fetchRootWorkspace(
rbacBaseEndpoint: string,
auth?: WorkspaceAuthRequest,
httpClient?: HttpClient,
): Promise<Workspace>;
function fetchDefaultWorkspace(
rbacBaseEndpoint: string,
auth?: WorkspaceAuthRequest,
httpClient?: HttpClient,
): Promise<Workspace>;Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| rbacBaseEndpoint | string | Yes | The RBAC service endpoint URL (e.g. https://console.redhat.com) |
| auth | WorkspaceAuthRequest | No | Authentication config. Pass { headers: { Authorization: 'Bearer ...' } } to include the user's JWT. |
| httpClient | HttpClient | No | A fetch-compatible function. When omitted, uses the global fetch. Allows injecting custom clients with interceptors or additional configuration. |
Types
The Workspace type is aligned with the RBAC v2 OpenAPI spec:
type Workspace = {
id: string;
type: 'root' | 'default' | 'standard' | 'ungrouped-hosts';
name: string;
created: string; // ISO 8601 date-time
modified: string; // ISO 8601 date-time
parent_id?: string;
description?: string;
};
type WorkspaceAuthRequest = {
headers?: Record<string, string>;
credentials?: RequestCredentials;
};
type HttpClient = typeof fetch;See Fetching Workspace IDs for Access Checks for full usage examples.
Usage Examples
Single Resource Check
Check if a user can delete a specific workspace:
import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
function WorkspaceActions({ workspaceId }) {
const { data: deleteCheck, loading } = useSelfAccessCheck({
relation: 'delete',
resource: {
id: workspaceId,
type: 'workspace',
reporter: { type: 'rbac' }
}
});
if (loading) return <Spinner />;
return (
<div>
<Button disabled={!deleteCheck?.allowed}>
Delete Workspace
</Button>
</div>
);
}Bulk Resource Check - Same Relation
Check which workspaces a user can delete:
import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
function WorkspaceList({ workspaces }) {
const { data: deleteChecks, loading } = useSelfAccessCheck({
relation: 'delete',
resources: workspaces.map(ws => ({
id: ws.id,
type: 'workspace',
reporter: { type: 'rbac' }
}))
});
if (loading) return <Spinner />;
return (
<ul>
{workspaces.map(workspace => {
const canDelete = deleteChecks?.find(
check => check.resource.id === workspace.id
)?.allowed;
return (
<li key={workspace.id}>
{workspace.name}
<DeleteButton disabled={!canDelete} />
</li>
);
})}
</ul>
);
}Bulk Resource Check - Nested Relations
Check multiple different permissions in a single request:
import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
function WorkspacePermissions({ workspaceId }) {
const { data: checks, loading } = useSelfAccessCheck({
resources: [
{ id: workspaceId, type: 'workspace', relation: 'view', reporter: { type: 'rbac' } },
{ id: workspaceId, type: 'workspace', relation: 'edit', reporter: { type: 'rbac' } },
{ id: workspaceId, type: 'workspace', relation: 'delete', reporter: { type: 'rbac' } }
]
});
if (loading) return <Spinner />;
const canView = checks?.find(c => c.relation === 'view')?.allowed;
const canEdit = checks?.find(c => c.relation === 'edit')?.allowed;
const canDelete = checks?.find(c => c.relation === 'delete')?.allowed;
return (
<div>
<p>View: {canView ? '✓' : '✗'}</p>
<p>Edit: {canEdit ? '✓' : '✗'}</p>
<p>Delete: {canDelete ? '✓' : '✗'}</p>
</div>
);
}Fetching Workspace IDs for Access Checks
When the resource you're checking access for is a workspace, you may need its UUID from RBAC first. Use fetchDefaultWorkspace or fetchRootWorkspace to retrieve it, then pass the ID into your access check resource:
import { fetchDefaultWorkspace, fetchRootWorkspace } from '@project-kessel/react-kessel-access-check';
// Fetch the default workspace ID for the current org
const workspace = await fetchDefaultWorkspace('https://console.redhat.com', {
headers: { 'Authorization': `Bearer ${token}` },
});
// Use workspace.id as the resource ID in access checks
const resource = {
id: workspace.id,
type: 'workspace',
reporter: { type: 'rbac' },
};
// Or fetch the root workspace
const rootWorkspace = await fetchRootWorkspace('https://console.redhat.com', {
headers: { 'Authorization': `Bearer ${token}` },
});With a custom fetch client:
const workspace = await fetchDefaultWorkspace(
'https://console.redhat.com',
{ headers: { 'Authorization': `Bearer ${token}` } },
myCustomFetchWithInterceptors,
);Conditional Rendering
Show different UI based on permissions:
import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
function ResourceActions({ resourceId, resourceType }) {
const { data: checks, loading } = useSelfAccessCheck({
resources: [
{ id: resourceId, type: resourceType, relation: 'view', reporter: { type: 'rbac' } },
{ id: resourceId, type: resourceType, relation: 'edit', reporter: { type: 'rbac' } },
{ id: resourceId, type: resourceType, relation: 'delete', reporter: { type: 'rbac' } }
]
});
if (loading) return <Spinner />;
const canView = checks?.find(c => c.relation === 'view')?.allowed;
const canEdit = checks?.find(c => c.relation === 'edit')?.allowed;
const canDelete = checks?.find(c => c.relation === 'delete')?.allowed;
if (!canView) {
return <div>You don't have permission to view this resource.</div>;
}
return (
<div>
<ViewContent />
{canEdit && <EditButton />}
{canDelete && <DeleteButton />}
{!canEdit && !canDelete && <div>Read-only access</div>}
</div>
);
}Filtering Resources
Filter a list to show only items the user can access:
import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
function FilteredWorkspaceList({ allWorkspaces }) {
const { data: viewChecks, loading } = useSelfAccessCheck({
relation: 'view',
resources: allWorkspaces.map(ws => ({
id: ws.id,
type: 'workspace',
reporter: { type: 'rbac' }
}))
});
if (loading) return <Spinner />;
const visibleWorkspaces = allWorkspaces.filter(ws =>
viewChecks?.find(check => check.resource.id === ws.id)?.allowed
);
return (
<div>
<h2>Your Workspaces ({visibleWorkspaces.length})</h2>
{visibleWorkspaces.map(ws => (
<WorkspaceCard key={ws.id} workspace={ws} />
))}
</div>
);
}Using Consistency Tokens
Ensure read-your-writes consistency after making changes:
import { useSelfAccessCheck } from '@project-kessel/react-kessel-access-check';
function WorkspaceManager({ workspaces }) {
const [consistencyToken, setConsistencyToken] = useState();
const { data: checks, consistencyToken: newToken } = useSelfAccessCheck({
relation: 'edit',
resources: workspaces.map(ws => ({
id: ws.id,
type: 'workspace',
reporter: { type: 'rbac' }
})),
options: {
consistency: {
minimizeLatency: false,
...(consistencyToken && { atLeastAsFresh: { token: consistencyToken } })
}
}
});
// Save token for next request
useEffect(() => {
if (newToken) {
setConsistencyToken(newToken.token);
}
}, [newToken]);
// ... rest of component
}Configuration
The AccessCheck.Provider requires both baseUrl and apiPath props to be provided. These should be configured based on your environment:
// Development
<AccessCheck.Provider
baseUrl="http://localhost:8000"
apiPath="/api/kessel/v1beta2"
>
<App />
</AccessCheck.Provider>
// Production
<AccessCheck.Provider
baseUrl="https://console.redhat.com"
apiPath="/api/kessel/v1beta2"
>
<App />
</AccessCheck.Provider>
// Environment-based
<AccessCheck.Provider
baseUrl={process.env.REACT_APP_API_URL}
apiPath={process.env.REACT_APP_ACCESS_CHECK_PATH}
>
<App />
</AccessCheck.Provider>TypeScript Support
This package includes full TypeScript definitions with function overloads.
import {
AccessCheck,
useSelfAccessCheck,
// Types
ConsistencyToken,
ReporterReference,
SelfAccessCheckResource,
SelfAccessCheckResourceWithRelation,
SelfAccessCheckError,
SelfAccessCheckResult,
BulkSelfAccessCheckResult,
SelfAccessCheckResultItemWithRelation
} from '@project-kessel/react-kessel-access-check';
// Resource type
const resource: SelfAccessCheckResource = {
id: 'ws-123',
type: 'workspace',
reporter: { type: 'rbac' },
// Can include additional properties
name: 'My Workspace'
};
// Single check
const singleCheck: SelfAccessCheckResult = useSelfAccessCheck({
relation: 'delete',
resource
});
// Bulk check - same relation
const bulkCheck: BulkSelfAccessCheckResult = useSelfAccessCheck({
relation: 'view',
resources: [
{ id: 'id1', type: 'workspace', reporter: { type: 'rbac' } },
{ id: 'id2', type: 'workspace', reporter: { type: 'rbac' } }
]
});
// Bulk check - nested relations
const nestedCheck: BulkSelfAccessCheckResult = useSelfAccessCheck({
resources: [
{ id: 'id1', type: 'workspace', relation: 'delete', reporter: { type: 'rbac' } },
{ id: 'id2', type: 'workspace', relation: 'edit', reporter: { type: 'rbac' } }
]
});Available Types
ConsistencyToken- Consistency token structure ({ token: string })ReporterReference- Reporter reference structure ({ type: string; instanceId?: string })SelfAccessCheckResource- Resource object with id, type, and reporterSelfAccessCheckResourceWithRelation- Resource with embedded relation (for nested checks)SelfAccessCheckError- Error object structureSelfAccessCheckResultItemWithRelation- Individual result item in bulk check responsesSelfAccessCheckResult- Return type for single resource checksBulkSelfAccessCheckResult- Return type for bulk resource checksWorkspace- RBAC workspace object (id, type, name, created, modified, parent_id, description)WorkspaceType- Workspace type enum ('root'|'default'|'standard'|'ungrouped-hosts')WorkspaceAuthRequest- Auth config for workspace helpers (headers, credentials)HttpClient- Afetch-compatible function type for custom HTTP clients
Architecture
This SDK uses a Provider/Context pattern for several key benefits:
- Centralized Data Layer: Single source of truth for access checks across your application
- Automatic Caching: Prevents duplicate requests for the same permission checks (planned)
- Request Deduplication: Multiple components can request the same check without triggering multiple API calls (planned)
- Lifecycle Awareness: Automatically handles component mounting/unmounting
- React Integration: Works seamlessly with Error Boundaries, Suspense, and other React features
- Reduced Prop Drilling: Access hooks from any component without passing props through the tree
Why Provider + Hooks?
The Provider/Context with hooks approach is preferred over direct async functions because it:
- Integrates cleanly with React's lifecycle (avoids setState on unmounted components)
- Provides automatic caching and deduplication (when implemented)
- Centralizes cross-cutting concerns (auth headers, retries, error handling, telemetry)
- Supports SSR/SSG via cache hydration
- Improves testability by swapping providers in unit tests
- Enhances dev ergonomics with predictable data flow
Direct async functions are better suited for:
- Simple, one-off actions (like form submissions)
- Code outside React components
- Cases requiring very specific control flow
Backend API Integration
This SDK interacts with the following backend REST endpoints:
Self Access Check
POST /api/kessel/v1beta2/checkself
Checks if the current user has the specified level of access to the provided resource.
Request Body:
{
"object": {
"resourceId": "e07f0bbd-4743-404f-8dca-14d92026b52c",
"resourceType": "workspace",
"reporter": {
"type": "rbac"
}
},
"relation": "view"
}Response:
{
"allowed": "ALLOWED_TRUE"
}Allowed values: ALLOWED_UNSPECIFIED, ALLOWED_TRUE, or ALLOWED_FALSE
Bulk Self Access Check
POST /api/kessel/v1beta2/checkselfbulk
Checks if the current user has the specified level of access to multiple resources.
Request Body:
{
"items": [
{
"object": {
"resourceId": "3f2a1c9e-5b6d-4a1f-8c3b-2d7e9f01a2b3",
"resourceType": "workspace",
"reporter": {
"type": "rbac"
}
},
"relation": "delete"
},
{
"object": {
"resourceId": "a8d4c2f1-9e0b-4d59-8a7e-3c1d5f6b8e90",
"resourceType": "workspace",
"reporter": {
"type": "rbac"
}
},
"relation": "view"
}
],
"consistency": {
"minimizeLatency": true
}
}Response:
{
"pairs": [
{
"request": {
"object": {
"resourceId": "3f2a1c9e-5b6d-4a1f-8c3b-2d7e9f01a2b3",
"resourceType": "workspace",
"reporter": {
"type": "rbac"
}
},
"relation": "delete"
},
"item": {
"allowed": "ALLOWED_TRUE"
}
},
{
"request": {
"object": {
"resourceId": "a8d4c2f1-9e0b-4d59-8a7e-3c1d5f6b8e90",
"resourceType": "workspace",
"reporter": {
"type": "rbac"
}
},
"relation": "view"
},
"item": {
"allowed": "ALLOWED_FALSE"
},
"error": {
"code": 403,
"message": "Access denied",
"details": []
}
}
],
"consistencyToken": {
"token": "ZGVhZGJlZWY="
}
}List Workspaces (RBAC v2)
GET /api/rbac/v2/workspaces/?type={type}
Retrieves workspaces filtered by type. Used by fetchRootWorkspace and fetchDefaultWorkspace.
Defined in the RBAC v2 OpenAPI spec.
Query Parameters:
| Parameter | Type | Description |
|-----------|------|-------------|
| type | root | default | standard | ungrouped-hosts | all | Filter workspaces by type |
Response (Workspaces.WorkspaceListResponse):
{
"meta": {
"count": 1,
"limit": 10,
"offset": 0
},
"links": {
"first": "/api/rbac/v2/workspaces/?limit=10&offset=0",
"next": null,
"previous": null,
"last": "/api/rbac/v2/workspaces/?limit=10&offset=0"
},
"data": [
{
"id": "e4277742-b91c-43f1-a185-b827e8574345",
"parent_id": "c1f729e2-3e2b-4f9e-b247-a4b568393e11",
"type": "root",
"name": "Root Workspace",
"description": "Organization root workspace",
"created": "2024-08-04T12:00:00Z",
"modified": "2024-08-04T12:00:00Z"
}
]
}Authentication
All requests include the user's JWT token in the Authorization header. The backend service authenticates the user and performs checks on their behalf. Cross-user checks are not supported.
Best Practices
1. Handle Loading States
Always handle the loading state while checks are in progress:
const { data, loading, error } = useSelfAccessCheck({
relation: 'delete',
resource: {
id: resourceId,
type: 'workspace',
reporter: { type: 'rbac' }
}
});
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;2. Use Bulk Checks for Multiple Resources
When checking the same permission across multiple resources, always use the bulk form:
// Good
const { data: checks } = useSelfAccessCheck({
relation: 'delete',
resources: workspaces.map(ws => ({
id: ws.id,
type: 'workspace',
reporter: { type: 'rbac' }
}))
});
// Avoid - triggers multiple API calls
workspaces.forEach(ws => {
const { data } = useSelfAccessCheck({
relation: 'delete',
resource: {
id: ws.id,
type: 'workspace',
reporter: { type: 'rbac' }
}
});
});3. Use Nested Relations for Different Permissions
When checking multiple different permissions, use nested relations:
const { data: checks } = useSelfAccessCheck({
resources: [
{ id: wsId, type: 'workspace', relation: 'view', reporter: { type: 'rbac' } },
{ id: wsId, type: 'workspace', relation: 'edit', reporter: { type: 'rbac' } },
{ id: wsId, type: 'workspace', relation: 'delete', reporter: { type: 'rbac' } }
]
});4. Access Checks Are Self-Service Only
All checks are performed for the currently authenticated user. You cannot check permissions on behalf of other users.
5. Frontend Checks Are Not Security
Access checks in the frontend provide UX improvements (hiding/disabling UI elements). Always enforce permissions on the backend when performing actual operations.
6. Minimize Provider Nesting
Place the AccessCheck.Provider as high as possible in your component tree, typically at the application root:
// Good
<AccessCheck.Provider>
<Router>
<App />
</Router>
</AccessCheck.Provider>
// Avoid
<Router>
<AccessCheck.Provider>
<ComponentA />
</AccessCheck.Provider>
<AccessCheck.Provider>
<ComponentB />
</AccessCheck.Provider>
</Router>7. Resource Types and Relations
Resource Types: workspace, group, inventory_group, etc.
Relations: view, edit, delete, create, move, rename, role_bindings_view, role_bindings_grant, role_bindings_revoke, etc.
Reporter Field: All resources must include a reporter object that identifies the service or system making the access check request:
{ type: 'rbac' }- For RBAC-based authorization (most common){ type: 'service', instanceId: 'console-ui' }- For service-specific authorization with an optional instance identifier
Resources are objects with id, type, and reporter properties, and can include additional metadata.
8. Handle Per-Item Errors
When using bulk checks, check for per-item errors:
const { data: checks } = useSelfAccessCheck({
relation: 'delete',
resources: workspaces.map(ws => ({
id: ws.id,
type: 'workspace',
reporter: { type: 'rbac' }
}))
});
const errors = checks?.filter(check => check.error);
if (errors?.length) {
console.error('Some checks failed:', errors);
}Troubleshooting
Common Errors
"useAccessCheckContext must be used within an AccessCheckProvider"
This error occurs when you try to use useSelfAccessCheck outside of an AccessCheck.Provider. Make sure your component is wrapped in the provider:
// ✗ Wrong
function App() {
const { data } = useSelfAccessCheck({
relation: 'view',
resource: { id: '1', type: 'workspace', reporter: { type: 'rbac' } }
});
return <div>...</div>;
}
// ✓ Correct
function App() {
return (
<AccessCheck.Provider baseUrl="https://api.example.com" apiPath="/api/kessel/v1beta2">
<MyComponent />
</AccessCheck.Provider>
);
}
function MyComponent() {
const { data } = useSelfAccessCheck({
relation: 'view',
resource: { id: '1', type: 'workspace', reporter: { type: 'rbac' } }
});
return <div>...</div>;
}Network or CORS Errors
Make sure:
- Your
baseUrlandapiPathare correctly configured - The backend API is accessible from your frontend
- CORS is properly configured on the backend to allow requests from your frontend domain
- JWT authentication cookies are being sent (credentials: 'include' is enabled by default)
Loading Never Completes
If the loading state never changes to false, check:
- Network tab in browser DevTools to see if the request is being made
- Console for any JavaScript errors
- That the API endpoint is responding correctly
- That there are no network errors or timeouts
