Skip to main content

Utilities

Rhino exports utility functions, adapters, and hooks beyond the main CRUD operations.

configureApi(options)​

Configure the Axios client used by all hooks. Call it once, before the first request.

src/config.ts
import { configureApi } from '@rhino-dev/rhino-react';

configureApi({
baseURL: 'https://api.yourapp.com/api',
onUnauthorized: () => {
// Called on a 401 from any request except login,
// after the token and user are cleared and AuthProvider has reset.
window.location.href = '/login';
},
});
OptionTypeDescription
baseURLstringAPI base URL (default: /api)
tenancy'path' | 'subdomain' | 'none'How data URLs carry the organization. 'path' (default): /{organization}/{model}. 'subdomain': /{model}, the host carries it. 'none': /{model}, no organization at all. See Tenancy and Data URLs.
routeGroupstring | nullRoute group used to build group-aware auth URLs (/{routeGroup}/auth/*). Pass null to clear it. Prefix-based groups only -- domain-based groups need no routeGroup.
routeGroupInDataPathbooleanAlso prepend routeGroup to data URLs: /{routeGroup}/{model} or /{routeGroup}/{organization}/{model}. Default false.
nestedPathstringPath segment useNestedOperations posts to. Default 'nested', the servers' default nested-operations path.
timeoutnumberRequest timeout in milliseconds. Default: none. 0 clears a timeout set earlier.
withCredentialsbooleanWhether requests send cookies. Default true (Sanctum cookie auth). Bearer-token apps such as React Native pass false.
onUnauthorized() => voidCallback on a 401 from any request except login. The token and user are cleared and AuthProvider resets first. Default on web: redirect to /.
onForbidden(error) => voidCallback on 403 responses (group membership denied). The token is not cleared. See Group-Aware Auth.
storageStorageAdapterA custom storage adapter for the token, user and organization -- e.g. a secure store on React Native or createElectronStorage() on desktop. See storage.

configureApi only changes the options you pass; calling it again with { timeout } leaves baseURL, withCredentials and the rest as they were.

src/config.ts
// Group-aware auth: scope auth URLs to a prefix-based route group
configureApi({
baseURL: '/api',
routeGroup: 'driver',
onForbidden: (error) => toast(error.response?.data?.message),
});
React Native

A native app authenticates with a bearer token, not cookies, and runs on networks that can stall. Turn cookies off, set a timeout, and navigate on onUnauthorized instead of the web's window.location redirect:

src/config.ts
configureApi({
baseURL: 'https://api.yourapp.com/api',
withCredentials: false,
timeout: 20_000, // 20-30 s suits mobile networks
onUnauthorized: () => navigation.navigate('Login'),
});

api​

The pre-configured Axios instance used by all hooks. Use it for custom API calls that aren't covered by the built-in hooks.

src/api.ts
import { api } from '@rhino-dev/rhino-react';

// Custom API call
const response = await api.get('/custom-endpoint');
const data = response.data;

// POST with data
const result = await api.post('/reports/generate', {
startDate: '2025-01-01',
endDate: '2025-01-31',
});

Automatic Features​

The api instance automatically:

  • Attaches auth token — reads token from storage and adds the Authorization: Bearer {token} header
  • Handles 401 responses — on any request except login, removes token and user from storage, resets AuthProvider (through the 'token' event), then calls onUnauthorized. A 401 from /auth/login or /{routeGroup}/auth/login is left to login(), which reports it as { success: false, status: 401 }
  • Handles 403 responses — keeps the token and calls onForbidden
  • Sends credentials — includes cookies for CORS requests (withCredentials: true, configurable)
  • Sets content type — application/json and Accept: application/json; a FormData body goes out as multipart/form-data (see File Uploads)

storage​

Platform-agnostic, synchronous storage adapter. It uses localStorage on web and AsyncStorage (behind an in-memory cache) on React Native, and you can swap in your own.

src/storage.ts
import { storage } from '@rhino-dev/rhino-react';

// Store a value
storage.setItem('theme', 'dark');

// Read a value
const theme = storage.getItem('theme'); // 'dark'

// Remove a value
storage.removeItem('theme');
MethodParametersReturnsDescription
getItem(key)stringstring | nullRead value
setItem(key, value)string, stringvoidWrite value
removeItem(key)stringvoidDelete value

Any object with these three synchronous methods is a StorageAdapter. Install one with configureApi({ storage }) or setStorageAdapter(adapter) (pass null to return to the platform default); getStorageAdapter() returns the active one. The storage export always delegates to the active adapter.

Keys Used Internally​

KeyDescription
tokenAPI authentication token (written by login() and useRegister, removed on logout and on a 401)
userThe authenticated user, JSON-encoded (removed on logout and on a 401)
organization_slugCurrent organization slug, read by the data hooks in 'path' tenancy
last_organizationThe last organization the user worked in (set alongside organization_slug)
route_groupActive route group (set on a group-aware login, cleared on logout)

The same list is exported as STORAGE_KEYS:

import { STORAGE_KEYS } from '@rhino-dev/rhino-react';
// ['token', 'user', 'organization_slug', 'last_organization', 'route_group']

On React Native, initStorage() loads exactly these keys from AsyncStorage into memory. Keys your app writes through storage are kept in memory and persisted, but are not reloaded by initStorage() after a restart -- read those from AsyncStorage yourself. See React Native -- Platform Adapters.

events​

Platform-agnostic event emitter for cross-component communication. Uses window.dispatchEvent on web.

src/events.ts
import { events } from '@rhino-dev/rhino-react';

// Emit an event
events.emit('organization_slug', 'acme-corp');

// Subscribe to events
const unsubscribe = events.subscribe('organization_slug', (newSlug) => {
console.log('Organization changed:', newSlug);
});

// Cleanup (e.g., in useEffect cleanup)
unsubscribe();
MethodParametersReturnsDescription
emit(key, value)string, anyvoidBroadcast event
subscribe(key, callback)string, (value) => void() => voidSubscribe, returns unsubscribe function

The library emits three keys: 'organization_slug' (read by useOrganization), 'route_group' (read by useRouteGroup) and 'token' (read by AuthProvider -- null when a 401 ends the session, the new token when useRegister starts one).

Cross-Tab Sync

On web, events are broadcast via StorageEvent, enabling cross-tab synchronization. When a user switches organizations in one tab, other tabs can detect and respond. On React Native the emitter is in-memory, within the app process.

extractPaginationFromHeaders(response)​

Parse pagination metadata from Axios response headers. Used internally by all list hooks, but available for custom API calls.

src/utils/pagination.ts
import { api, extractPaginationFromHeaders } from '@rhino-dev/rhino-react';

const response = await api.get('/posts?page=2&per_page=15');
const pagination = extractPaginationFromHeaders(response);

// {
// currentPage: 2,
// lastPage: 10,
// perPage: 15,
// total: 143
// }

Headers Parsed​

HeaderMapped To
X-Current-PagecurrentPage
X-Last-PagelastPage
X-Per-PageperPage
X-Totaltotal

Returns null if the headers are not present.

src/types.ts
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}

Outside React​

The model hooks are thin wrappers over plain functions that build their URLs, their cache keys and their requests. The same functions are exported, so code that runs outside a component -- a push-notification handler, a background task, a router loader, a prefetch -- produces exactly the URLs and keys the hooks use.

ExportReturns
buildModelUrl(model, options?, target?)The URL a model hook requests, relative to baseURL
modelKeysThe query keys the model hooks register
fetchModelIndex(model, options?, context?)One page of a list -- what useModelIndex caches
fetchModelShow(model, id, options?, context?)One record -- what useModelShow caches

All of them honor tenancy and routeGroupInDataPath, and read the organization from storage when you do not pass one.

buildModelUrl​

buildModelUrl(model, options?, { id?, organization?, suffix? })

import { buildModelUrl } from '@rhino-dev/rhino-react';

buildModelUrl('posts', { filters: { status: 'published' }, page: 2 });
// '/acme/posts?filter%5Bstatus%5D=published&page=2'
// (the query string is URL-encoded: filter[status]=published&page=2)
TargetCallURL (default tenancy)
indexbuildModelUrl('posts', options)/{organization}/posts?…
showbuildModelUrl('posts', options, { id })/{organization}/posts/{id}?…
computedbuildModelUrl('posts', options, { suffix: 'computed' })/{organization}/posts/computed?…
trashedbuildModelUrl('posts', options, { suffix: 'trashed' })/{organization}/posts/trashed?…
auditbuildModelUrl('posts', options, { id, suffix: 'audit' })/{organization}/posts/{id}/audit?…

Each target serializes the options its hook serializes: a show URL carries no scope, search or pagination, and an audit URL carries pagination only. Any other suffix (e.g. 'restore') yields the bare path. organization defaults to the stored slug; in 'path' tenancy it throws when there is none.

modelKeys​

Called with every argument, a modelKeys method returns the exact key its hook registers. Called with fewer, it returns a prefix that matches a group of queries -- which is what invalidateQueries wants.

MethodHook
modelKeys.index(model, options?, organization?)useModelIndex
modelKeys.infinite(model, options?, organization?)useModelInfinite
modelKeys.show(model, id?, options?, organization?)useModelShow
modelKeys.computed(model, options?, organization?)useModelComputedAttributes
modelKeys.trashed(model, options?, organization?)useModelTrashed
modelKeys.audit(model, id?, options?, organization?)useModelAudit
modelKeys.all(model)Every query above, for one model
import { modelKeys } from '@rhino-dev/rhino-react';

modelKeys.index('posts'); // every posts list
modelKeys.index('posts', { page: 2 }); // the key of useModelIndex('posts', { page: 2 })
modelKeys.show('posts'); // every posts record
modelKeys.show('posts', 5); // every query of post 5
modelKeys.show('posts', 5, {}); // the key of useModelShow('posts', 5)

queryClient.invalidateQueries({ queryKey: modelKeys.show('posts', 5) });
queryClient.invalidateQueries(modelKeys.all('posts'));
  • A hook called without options uses {}, so pass {} for its exact key: useModelShow('posts', 5) registers modelKeys.show('posts', 5, {}).
  • organization defaults to the stored slug, which is what the hook reads.
  • modelKeys.all(model) is a filter, not a key. The hooks' keys share no common prefix, so all returns { predicate } and is passed to invalidateQueries (or refetchQueries, removeQueries) directly, not as { queryKey }.

fetchModelIndex / fetchModelShow​

fetchModelIndex<T>(model, options?, { organization? }) resolves to { data, pagination }; fetchModelShow<T>(model, id, options?, { organization? }) resolves to the record. Pair them with modelKeys in queryClient.fetchQuery or prefetchQuery and the result lands in the cache entry the hook will read:

src/notifications.ts
import { queryClient } from './queryClient';
import { modelKeys, fetchModelShow } from '@rhino-dev/rhino-react';

// A push notification says a trip changed: refresh what is on screen,
// and have the trip ready before the user taps through.
export async function onTripUpdated(tripId: number) {
queryClient.invalidateQueries({ queryKey: modelKeys.index('trips') });

await queryClient.prefetchQuery({
queryKey: modelKeys.show('trips', tripId, {}),
queryFn: () => fetchModelShow<Trip>('trips', tripId),
});
}
src/screens/TripScreen.tsx
// Renders from the prefetched cache entry -- same key, no second request.
const { data: trip } = useModelShow<Trip>('trips', tripId);

Both reject in 'path' tenancy when no organization is passed or stored.

cn(...inputs)​

Utility function for merging CSS classes. Combines clsx and tailwind-merge for conflict-free class merging.

src/utils/cn.ts
import { cn } from '@rhino-dev/rhino-react';

// Basic usage
cn('px-4 py-2', 'bg-blue-500'); // 'px-4 py-2 bg-blue-500'

// Conditional classes
cn('px-4 py-2', isActive && 'bg-blue-500', isDisabled && 'opacity-50');

// Tailwind conflict resolution
cn('px-4', 'px-6'); // 'px-6' (later wins)
cn('text-red-500', 'text-blue-500'); // 'text-blue-500'

useToast()​

Toast notification state management hook.

src/components/MyComponent.tsx
import { useToast } from '@rhino-dev/rhino-react';

function MyComponent() {
const { toast, dismiss, toasts } = useToast();

const showSuccess = () => {
const { id } = toast({
title: 'Success!',
description: 'Your changes have been saved.',
});

// Auto-dismiss after 3 seconds
setTimeout(() => dismiss(id), 3000);
};

const showError = () => {
toast({
title: 'Error',
description: 'Something went wrong. Please try again.',
variant: 'destructive',
});
};

return (
<div>
<button onClick={showSuccess}>Save</button>
<button onClick={showError}>Trigger Error</button>

{/* Render toasts */}
<div style={{ position: 'fixed', bottom: 20, right: 20 }}>
{toasts.map((t) => (
<div key={t.id} style={{ padding: '1rem', background: '#333', color: '#fff', marginBottom: '0.5rem', borderRadius: '8px' }}>
<strong>{t.title}</strong>
<p>{t.description}</p>
<button onClick={() => dismiss(t.id)}>×</button>
</div>
))}
</div>
</div>
);
}

useModelAudit(model, id, options?, queryOptions?)​

Fetch the audit trail (change history) for a specific record. options takes page and perPage; the trailing queryOptions takes any useQuery option except queryKey and queryFn (see TanStack Query Options). The query never runs without an id.

src/components/PostHistory.tsx
import { useModelAudit } from '@rhino-dev/rhino-react';

function PostHistory({ postId }) {
const { data: response, isLoading } = useModelAudit('posts', postId, {
page: 1,
perPage: 50,
});

const logs = response?.data || [];
const pagination = response?.pagination;

if (isLoading) return <div>Loading history...</div>;

return (
<div>
<h3>Change History</h3>
<table>
<thead>
<tr>
<th>Action</th>
<th>Changes</th>
<th>By</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>{log.action}</td>
<td>
{log.action === 'updated' && log.old_values && (
<ul>
{Object.keys(log.new_values || {}).map((field) => (
<li key={field}>
<strong>{field}:</strong>{' '}
<span style={{ textDecoration: 'line-through', color: 'red' }}>
{log.old_values[field]}
</span>{' → '}
<span style={{ color: 'green' }}>
{log.new_values[field]}
</span>
</li>
))}
</ul>
)}
{log.action === 'created' && (
<span>Record created</span>
)}
{log.action === 'deleted' && (
<span>Record deleted</span>
)}
</td>
<td>User #{log.user_id}</td>
<td>{new Date(log.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

API Request: GET /api/{organization}/posts/{id}/audit?page=1&per_page=50

Audit Log Entry Type​

src/types.ts
interface AuditLog {
id: number;
action: 'created' | 'updated' | 'deleted' | 'force_deleted' | 'restored';
user_id: number;
model_type: string;
model_id: number;
old_values: Record<string, any> | null;
new_values: Record<string, any> | null;
ip_address: string;
user_agent: string;
created_at: string;
}