Skip to main content

CRUD Hooks

Complete CRUD operations with TanStack Query -- zero boilerplate. Every hook automatically scopes requests to the current organization, manages caching, and invalidates related queries on mutation success.

Every hook also takes a trailing TanStack Query options argument -- queryOptions on the query hooks, mutationOptions on the mutation hooks -- for polling, dependent queries, select, staleTime, onSuccess and the rest. See TanStack Query Options.

All five hooks are imported from @rhino-dev/rhino-react:

src/hooks/crud.ts
import {
useModelIndex,
useModelShow,
useModelStore,
useModelUpdate,
useModelDelete,
} from '@rhino-dev/rhino-react';

useModelIndex(model, options?, queryOptions?)​

Fetch a paginated list of records with filtering, sorting, search, and more.

src/components/PostsList.tsx
const { data: response, isLoading, error, refetch } = useModelIndex('posts', {
filters: { status: 'published', user_id: 1 },
includes: ['user', 'comments'],
sort: '-created_at',
search: 'laravel',
page: 1,
perPage: 20,
fields: ['id', 'title', 'excerpt'],
});

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

Parameters​

ParameterTypeDescription
modelstringThe model name matching your API resource (e.g., 'posts', 'users').
optionsModelQueryOptionsOptional. Filtering, sorting, pagination, search, includes, and field selection.
queryOptionsModelQueryHookOptionsOptional. Any useQuery option except queryKey and queryFn -- see TanStack Query Options.

The full signature is useModelIndex<T, TData = QueryResponse<T>>(model, options?, queryOptions?). TData is the type select returns, and it becomes the type of data.

For a "load more" or infinite-scroll list that accumulates pages, use useModelInfinite instead.

Return Value​

Returns a standard TanStack Query result object:

PropertyTypeDescription
dataQueryResponse<T> | undefinedContains data (array of records) and pagination metadata.
isLoadingbooleantrue during the initial fetch.
errorError | nullThe error object if the request failed.
refetch() => PromiseManually re-trigger the query.

ModelQueryOptions​

src/types.ts
interface ModelQueryOptions {
filters?: Record<string, any>;
includes?: string[];
sort?: string;
fields?: string[];
search?: string;
scope?: string;
page?: number;
perPage?: number;
}
OptionTypeExampleDescription
filtersRecord<string, any>{ status: 'published' }Key-value pairs translated to ?filter[key]=value.
includesstring[]['user', 'comments']Eager-load relationships. Sent as ?include=user,comments.
sortstring'-created_at'Sort field. Prefix with - for descending. Comma-separate for multiple: '-created_at,title'.
fieldsstring[]['id', 'title']Sparse fieldset. Only return these fields.
searchstring'laravel'Full-text search query sent as ?search=laravel.
scopestring'availableForDrivers'Selects a server-declared named scope. Sent as ?scope=availableForDrivers. Applies to useModelIndex and useModelTrashed (not useModelShow). Omit to use the model's default scope; an unknown scope 403s.
pagenumber1Current page number.
perPagenumber20Number of records per page. Sent as ?per_page=20.

QueryResponse Type​

src/types.ts
interface QueryResponse<T> {
data: T[];
pagination: PaginationMeta | null;
}

interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
info

Pagination metadata is extracted from response headers, not the response body. The hook handles this automatically -- you just read response.pagination.

Complete Posts List Example​

A full component with search, filtering, pagination controls, loading state, and error handling:

src/components/PostsList.tsx
import { useState } from 'react';
import { useModelIndex } from '@rhino-dev/rhino-react';

function PostsList() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');

const { data: response, isLoading, error, refetch } = useModelIndex('posts', {
filters: statusFilter !== 'all' ? { status: statusFilter } : undefined,
includes: ['user', 'tags'],
sort: '-created_at',
search: search || undefined,
page,
perPage: 15,
fields: ['id', 'title', 'excerpt', 'status', 'created_at'],
});

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

if (error) {
return (
<div>
<p>Failed to load posts: {error.message}</p>
<button onClick={() => refetch()}>Retry</button>
</div>
);
}

return (
<div>
{/* Search */}
<input
type="text"
placeholder="Search posts..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1); // Reset to first page on new search
}}
/>

{/* Filter */}
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
>
<option value="all">All Statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>

{/* Loading */}
{isLoading && <p>Loading posts...</p>}

{/* Posts List */}
{!isLoading && posts.length === 0 && <p>No posts found.</p>}

{posts.map((post) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
<small>
By {post.user?.name} | {new Date(post.created_at).toLocaleDateString()}
</small>
<span>{post.status}</span>
<div>
{post.tags?.map((tag) => (
<span key={tag.id}>{tag.name}</span>
))}
</div>
</article>
))}

{/* Pagination Controls */}
{pagination && (
<nav>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={pagination.currentPage <= 1}
>
Previous
</button>

<span>
Page {pagination.currentPage} of {pagination.lastPage}
{' '}({pagination.total} total)
</span>

<button
onClick={() => setPage((p) => Math.min(pagination.lastPage, p + 1))}
disabled={pagination.currentPage >= pagination.lastPage}
>
Next
</button>
</nav>
)}
</div>
);
}
tip

When the user changes search or filter values, reset page back to 1. Otherwise they may end up on a page that no longer exists in the new result set.


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

Fetch a single record by ID with optional relationship eager-loading.

src/components/PostDetail.tsx
const { data: post, isLoading, error } = useModelShow('posts', 42, {
includes: ['user', 'comments'],
});

Parameters​

ParameterTypeDescription
modelstringThe model name (e.g., 'posts').
idstring | numberThe record ID. The query is disabled when id is falsy.
optionsModelQueryOptionsOptional. Supports includes, fields, filters, and sort.
queryOptionsModelQueryHookOptionsOptional. Any useQuery option except queryKey and queryFn. enabled can only narrow the hook's own guard: the query never runs without an id. See TanStack Query Options.

The full signature is useModelShow<T, TData = T>(model, id, options?, queryOptions?).

info

The id argument is whatever the server's route key expects in the URL. For models with a configured route key (see the server's Models — Route Key), pass the route-key value — e.g., useModelShow('jobs', job.hash_id) — no client-side configuration is needed. Because react-query cache keys embed the value you pass, use the route key consistently across index, show, and mutations so invalidation and cache hits line up.

Return Value​

PropertyTypeDescription
dataT | undefinedThe model record directly -- not wrapped in a data property.
isLoadingbooleantrue during the initial fetch.
errorError | nullThe error object if the request failed.
refetch() => PromiseManually re-trigger the query.
warning

Unlike useModelIndex, which returns { data: T[], pagination }, useModelShow returns the record directly. There is no wrapper object.

Detail Page Example​

A component that displays a post with its author and comments:

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

function PostDetail({ postId }) {
const { data: post, isLoading, error } = useModelShow('posts', postId, {
includes: ['user', 'comments', 'tags'],
});

if (isLoading) {
return <div>Loading post...</div>;
}

if (error) {
return <div>Error loading post: {error.message}</div>;
}

if (!post) {
return <div>Post not found.</div>;
}

return (
<article>
<header>
<h1>{post.title}</h1>
<p>
By {post.user?.name} | Published{' '}
{new Date(post.created_at).toLocaleDateString()}
</p>
<div>
{post.tags?.map((tag) => (
<span key={tag.id}>{tag.name}</span>
))}
</div>
</header>

<div dangerouslySetInnerHTML={{ __html: post.body }} />

<section>
<h2>Comments ({post.comments?.length || 0})</h2>
{post.comments?.map((comment) => (
<div key={comment.id}>
<strong>{comment.author_name}</strong>
<p>{comment.body}</p>
<small>{new Date(comment.created_at).toLocaleDateString()}</small>
</div>
))}
</section>
</article>
);
}
tip

The query is automatically disabled when id is falsy (null, undefined, 0, ''). This makes it safe to use in components where the ID may not be available immediately, such as when waiting for route params.


useModelStore(model, mutationOptions?)​

Create a new record via a POST request. Returns a TanStack Query mutation object.

src/components/CreatePostForm.tsx
const createPost = useModelStore('posts');

createPost.mutate(
{ title: 'New Post', content: 'Hello world!' },
{
onSuccess: (newPost) => console.log('Created:', newPost),
onError: (error) => console.error('Failed:', error),
}
);

Parameters​

ParameterTypeDescription
modelstringThe model name (e.g., 'posts').
mutationOptionsModelMutationHookOptionsOptional. Any useMutation option except mutationFn -- see TanStack Query Options.

Mutation Input​

Pass the new record data directly to mutate():

src/components/CreatePostForm.tsx
createPost.mutate({ title: 'My Post', body: 'Content here', status: 'draft' });

Pass a FormData instead to upload files -- it is sent as multipart/form-data. See File Uploads.

Return Value​

PropertyTypeDescription
mutate(data, options?)functionTrigger the POST request. Fire-and-forget.
mutateAsync(data, options?)functionSame as mutate but returns a Promise.
isPendingbooleantrue while the request is in flight.
isSuccessbooleantrue after a successful creation.
isErrorbooleantrue if the mutation failed.
errorError | nullThe error object on failure.
dataT | undefinedThe created record returned by the server.
reset()functionReset the mutation state back to idle.
info

On success, useModelStore automatically invalidates all useModelIndex, useModelInfinite and useModelShow queries for the same model. Your lists refresh without any manual cache management.

Create Form Example​

A full create form with inputs, loading state, error display, and list refresh:

src/components/CreatePostForm.tsx
import { useState } from 'react';
import { useModelStore, useModelIndex } from '@rhino-dev/rhino-react';

function CreatePostForm() {
const createPost = useModelStore('posts');
const { refetch } = useModelIndex('posts');

const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [errors, setErrors] = useState({});

const handleSubmit = (e) => {
e.preventDefault();
setErrors({});

createPost.mutate(
{ title, body, status: 'draft' },
{
onSuccess: (newPost) => {
// Reset form
setTitle('');
setBody('');
// List auto-refreshes via query invalidation, but you can also
// call refetch() explicitly if needed
},
onError: (error) => {
if (error.response?.status === 422) {
// Laravel validation errors
setErrors(error.response.data.errors || {});
}
},
}
);
};

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
{errors.title && <p style={{ color: 'red' }}>{errors.title[0]}</p>}
</div>

<div>
<label htmlFor="body">Body</label>
<textarea
id="body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your post..."
rows={6}
/>
{errors.body && <p style={{ color: 'red' }}>{errors.body[0]}</p>}
</div>

<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>

{createPost.isError && !Object.keys(errors).length && (
<p style={{ color: 'red' }}>
Something went wrong. Please try again.
</p>
)}
</form>
);
}

useModelUpdate(model, mutationOptions?)​

Update an existing record via a PUT request (or a POST with an X-HTTP-Method-Override: PUT header when you pass FormData -- see File Uploads). Returns a TanStack Query mutation object.

src/components/EditPostForm.tsx
const updatePost = useModelUpdate('posts');

updatePost.mutate(
{ id: 42, data: { title: 'Updated Title', status: 'published' } },
{
onSuccess: (updated) => console.log('Updated:', updated),
}
);

Parameters​

ParameterTypeDescription
modelstringThe model name (e.g., 'posts').
mutationOptionsModelMutationHookOptionsOptional. Any useMutation option except mutationFn -- see TanStack Query Options.

Mutation Input​

Pass an object with id and data:

src/components/EditPostForm.tsx
updatePost.mutate({
id: 42, // The record ID
data: { title: 'New Title', status: 'published' }, // Fields to update
});
PropertyTypeDescription
idstring | numberThe ID of the record to update. For models with a server-side route key, pass the route-key value (e.g., job.hash_id).
dataRecord<string, any> | FormDataThe fields to update. Only include changed fields. A FormData uploads files -- see File Uploads.

Return Value​

Same mutation shape as useModelStore -- see the table above for mutate, isPending, isSuccess, error, etc.

info

On success, useModelUpdate automatically invalidates all useModelIndex, useModelInfinite and useModelShow queries for the same model, ensuring your UI stays in sync.

Edit Form Example​

A pre-populated edit form that loads existing data with useModelShow and saves with useModelUpdate:

src/components/EditPostForm.tsx
import { useState, useEffect } from 'react';
import { useModelShow, useModelUpdate } from '@rhino-dev/rhino-react';

function EditPostForm({ postId, onSaved }) {
const { data: post, isLoading: isLoadingPost } = useModelShow('posts', postId);
const updatePost = useModelUpdate('posts');

const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [status, setStatus] = useState('draft');
const [errors, setErrors] = useState({});

// Populate form when post data loads
useEffect(() => {
if (post) {
setTitle(post.title || '');
setBody(post.body || '');
setStatus(post.status || 'draft');
}
}, [post]);

const handleSubmit = (e) => {
e.preventDefault();
setErrors({});

updatePost.mutate(
{ id: postId, data: { title, body, status } },
{
onSuccess: (updatedPost) => {
onSaved?.(updatedPost);
},
onError: (error) => {
if (error.response?.status === 422) {
setErrors(error.response.data.errors || {});
}
},
}
);
};

if (isLoadingPost) {
return <div>Loading post...</div>;
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
{errors.title && <p style={{ color: 'red' }}>{errors.title[0]}</p>}
</div>

<div>
<label htmlFor="body">Body</label>
<textarea
id="body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={8}
/>
{errors.body && <p style={{ color: 'red' }}>{errors.body[0]}</p>}
</div>

<div>
<label htmlFor="status">Status</label>
<select
id="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</div>

<button type="submit" disabled={updatePost.isPending}>
{updatePost.isPending ? 'Saving...' : 'Save Changes'}
</button>

{updatePost.isError && !Object.keys(errors).length && (
<p style={{ color: 'red' }}>Failed to save. Please try again.</p>
)}
</form>
);
}

useModelDelete(model, mutationOptions?)​

Soft delete a record via a DELETE request. Returns a TanStack Query mutation object.

src/components/DeleteButton.tsx
const deletePost = useModelDelete('posts');

deletePost.mutate(42, {
onSuccess: () => console.log('Deleted'),
});

Parameters​

ParameterTypeDescription
modelstringThe model name (e.g., 'posts').
mutationOptionsModelMutationHookOptionsOptional. Any useMutation option except mutationFn -- see TanStack Query Options.

Mutation Input​

Pass the record ID directly to mutate():

src/components/DeleteButton.tsx
deletePost.mutate(42);

For models with a server-side route key, pass the route-key value instead (e.g., deleteJob.mutate(job.hash_id)).

Return Value​

Same mutation shape as useModelStore -- see the table above for mutate, isPending, isSuccess, error, etc.

info

On success, useModelDelete automatically invalidates all useModelIndex, useModelInfinite and useModelShow queries for the same model.

tip

useModelDelete performs a soft delete. The record is not permanently removed from the database -- it can be restored later using useModelRestore. See the Soft Deletes page for the full trash/restore/force-delete lifecycle.

Delete Button with Confirmation​

src/components/DeleteButton.tsx
function DeleteButton({ postId, onDeleted }) {
const deletePost = useModelDelete('posts');

const handleDelete = () => {
if (window.confirm('Are you sure you want to move this post to trash?')) {
deletePost.mutate(postId, {
onSuccess: () => {
onDeleted?.();
},
});
}
};

return (
<button onClick={handleDelete} disabled={deletePost.isPending}>
{deletePost.isPending ? 'Deleting...' : 'Delete'}
</button>
);
}

File Uploads​

useModelStore and useModelUpdate accept a FormData wherever they accept a plain object. Append your fields and files to it and pass it to mutate():

src/components/AvatarUpload.tsx
import { useModelStore, useModelUpdate } from '@rhino-dev/rhino-react';

function AvatarUpload({ userId }: { userId: number }) {
const createDocument = useModelStore('documents');
const updateUser = useModelUpdate('users');

const upload = (file: File) => {
// Create: POST /api/{organization}/documents (multipart/form-data)
const doc = new FormData();
doc.append('title', file.name);
doc.append('file', file);
createDocument.mutate(doc);

// Update: POST /api/{organization}/users/{id} + X-HTTP-Method-Override: PUT (multipart/form-data)
const form = new FormData();
form.append('avatar', file);
updateUser.mutate({ id: userId, data: form });
};

return <input type="file" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />;
}
HookPlain objectFormData
useModelStorePOST {url} as JSONPOST {url} as multipart/form-data
useModelUpdatePUT {url}/{id} as JSONPOST {url}/{id} as multipart/form-data, with the header X-HTTP-Method-Override: PUT

A file update goes out as POST because PHP does not parse a multipart body on a PUT request. The X-HTTP-Method-Override: PUT header tells Laravel to route it as an update.

The override is a header, not a _method form field, on purpose: Rhino treats every form field as an attribute of the record, so a _method field is rejected with 403 ("not allowed to set the following field(s): _method") whenever the policy restricts which attributes may be written. If you append your own _method to the form anyway, the hook sends the form as is and leaves the header out.

The file itself is an attribute like any other: the field name must be writable under the model's policy and accepted by its validation rules (for example 'avatar' => 'nullable|file').

Cross-origin browsers

X-HTTP-Method-Override is a custom header, so a cross-origin browser request needs it in the server's CORS allowed_headers. Laravel's default (*) already allows it. React Native is not subject to CORS.

Rails and NestJS backends

Method override is honored by Laravel out of the box. A Rails API-only app does not include Rack::MethodOverride (which reads the same header), and NestJS has no method override by default, so a FormData update reaches those servers as a plain POST. Add a method-override middleware on the server, or send the update as JSON.

On React Native, append a file as an object with uri, name and type -- the shape React Native's FormData uploads:

src/screens/ProfilePhoto.tsx
import { useModelUpdate } from '@rhino-dev/rhino-react';

function useUploadPhoto(userId: number) {
const updateUser = useModelUpdate('users');

return (asset: { uri: string; fileName?: string; mimeType?: string }) => {
const form = new FormData();
form.append('photo', {
uri: asset.uri,
name: asset.fileName ?? 'photo.jpg',
type: asset.mimeType ?? 'image/jpeg',
} as any);

return updateUser.mutateAsync({ id: userId, data: form });
};
}

The cache invalidation is the same as for a JSON mutation.


TanStack Query Options​

Every hook takes the TanStack Query options you would pass to useQuery or useMutation as its last argument. The hook keeps ownership of the request and the cache key; everything else is yours.

Query options​

The query hooks take an optional trailing queryOptions:

useModelIndex<T, TData>(model, options?, queryOptions?)
useModelShow<T, TData>(model, id, options?, queryOptions?)
useModelTrashed<T, TData>(model, options?, queryOptions?)
useModelComputedAttributes<T, TData>(model, options?, queryOptions?)
useModelAudit<TData>(model, id, options?, queryOptions?)

Its type is ModelQueryHookOptions -- UseQueryOptions without queryKey and queryFn, which the hook builds itself. staleTime, gcTime, refetchInterval, refetchOnWindowFocus, placeholderData, retry, select and enabled all pass through.

Poll a record while a screen is open:

src/screens/TripStatus.tsx
const { data: trip } = useModelShow<Trip>('trips', tripId, {}, {
refetchInterval: 15_000, // GET /api/{organization}/trips/{tripId} every 15 s
});

Wait for one query before starting another:

src/screens/TripStops.tsx
const { data: trip } = useModelShow<Trip>('trips', tripId);

const { data: stops } = useModelIndex<Stop>(
'stops',
{ filters: { trip_id: trip?.id } },
{ enabled: !!trip }, // no request until the trip has loaded
);

Reshape the data with select. Its return type flows into data through the second generic:

src/components/PostTitles.tsx
const { data: titles } = useModelIndex<Post, string[]>('posts', {}, {
select: (response) => response.data.map((post) => post.title),
});
// titles: string[] | undefined

The rules:

  • enabled narrows, never widens. It is AND-ed with the hook's own guard -- an organization in context (in 'path' tenancy) and, for useModelShow / useModelAudit, an id. enabled: true does not make a show query run without an id. Both TanStack Query v5 forms work: a boolean, and a function of the query.
  • queryKey and queryFn cannot be overridden. The hook always uses its own.
  • Query keys never include queryOptions. Two components that call useModelIndex('posts', { page: 1 }) with different queryOptions share one cache entry and one request. select runs per component, so each can shape the shared data differently.

Mutation options​

The mutation hooks take an optional trailing mutationOptions:

useModelStore<T, TContext>(model, mutationOptions?)
useModelUpdate<T, TContext>(model, mutationOptions?)
useModelDelete<T, TContext>(model, mutationOptions?)
useModelRestore<T, TContext>(model, mutationOptions?)
useModelForceDelete<T, TContext>(model, mutationOptions?)
useNestedOperations<TContext>(mutationOptions?)

Its type is ModelMutationHookOptions -- UseMutationOptions without mutationFn. Use it for behavior that belongs to every call of the hook: a toast, navigation, an optimistic update, extra invalidation.

src/screens/NewTrip.tsx
const createTrip = useModelStore<Trip>('trips', {
onSuccess: (trip) => navigation.navigate('Trip', { id: trip.id }),
onError: (error) => toast(error.message),
});

createTrip.mutate({ origin, destination }, {
onSuccess: () => analytics.track('trip_created'), // per-call callback
});

The order on success is fixed:

  1. The hook's built-in cache invalidation (see Automatic Cache Invalidation).
  2. Your hook-level onSuccess.
  3. Any per-call onSuccess passed to mutate() / mutateAsync().

onMutate, onError and onSettled pass through unchanged; mutationFn cannot be overridden.


Error Handling​

All mutation hooks (useModelStore, useModelUpdate, useModelDelete) support the standard TanStack Query onError callback. The error object from Axios includes the full HTTP response, making it straightforward to handle validation errors, authorization failures, and server errors.

Handling Validation Errors (422)​

Laravel returns validation errors in a predictable format. Each field maps to an array of error messages:

src/utils/errorHandling.ts
const createPost = useModelStore('posts');

createPost.mutate(data, {
onError: (error) => {
if (error.response?.status === 422) {
const validationErrors = error.response.data.errors;
// {
// title: ['The title field is required.'],
// body: ['The body must be at least 10 characters.'],
// }
setErrors(validationErrors);
}
},
});

Handling Authorization Errors (403)​

src/utils/errorHandling.ts
createPost.mutate(data, {
onError: (error) => {
if (error.response?.status === 403) {
alert('You do not have permission to perform this action.');
}
},
});

Handling Not Found Errors (404)​

src/utils/errorHandling.ts
updatePost.mutate({ id: postId, data }, {
onError: (error) => {
if (error.response?.status === 404) {
alert('This record no longer exists.');
}
},
});

Comprehensive Error Handler​

A reusable pattern for handling all common error types:

src/utils/errorHandling.ts
function handleMutationError(error, setErrors) {
const status = error.response?.status;

switch (status) {
case 422:
// Validation errors
setErrors(error.response.data.errors || {});
break;
case 403:
alert('You are not authorized to perform this action.');
break;
case 404:
alert('The requested resource was not found.');
break;
case 500:
alert('A server error occurred. Please try again later.');
break;
default:
alert('An unexpected error occurred.');
break;
}
}

// Usage:
createPost.mutate(data, {
onError: (error) => handleMutationError(error, setErrors),
});

Automatic Cache Invalidation​

All mutation hooks automatically invalidate related queries on success. You do not need to manually manage the TanStack Query cache in most cases.

HookInvalidates
useModelStoreuseModelIndex + useModelInfinite + useModelShow for the same model
useModelUpdateuseModelIndex + useModelInfinite + useModelShow for the same model
useModelDeleteuseModelIndex + useModelInfinite + useModelShow for the same model
useModelRestoreuseModelIndex + useModelInfinite + useModelTrashed + useModelShow for the same model
useModelForceDeleteuseModelTrashed for the same model
useNestedOperationsuseModelIndex + useModelInfinite + useModelShow for every model named in the operations

This means that after creating, updating, or deleting a record, any component using useModelIndex, useModelInfinite or useModelShow for that model will automatically refetch its data.

To invalidate or prefetch these queries yourself -- from a push-notification handler, a websocket message, or a mutation the hooks do not cover -- use modelKeys, which returns the exact keys the hooks register:

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

queryClient.invalidateQueries({ queryKey: modelKeys.index('posts') }); // every posts list
queryClient.invalidateQueries(modelKeys.all('posts')); // every posts query of any kind

Complete CRUD Example​

A full PostsManager component that combines all five hooks -- listing, creating, editing, deleting, with search and pagination:

src/pages/PostsManager.tsx
import { useState } from 'react';
import {
useModelIndex,
useModelShow,
useModelStore,
useModelUpdate,
useModelDelete,
} from '@rhino-dev/rhino-react';

function PostsManager() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [editingId, setEditingId] = useState(null);
const [showCreateForm, setShowCreateForm] = useState(false);

// ---- List posts ----
const {
data: response,
isLoading,
error,
} = useModelIndex('posts', {
includes: ['user'],
sort: '-created_at',
search: search || undefined,
page,
perPage: 10,
});

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

if (error) {
return <p>Failed to load posts: {error.message}</p>;
}

return (
<div>
<h1>Posts</h1>

{/* Search */}
<input
type="text"
placeholder="Search posts..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>

{/* Create toggle */}
<button onClick={() => setShowCreateForm(!showCreateForm)}>
{showCreateForm ? 'Cancel' : 'New Post'}
</button>

{/* Create Form */}
{showCreateForm && (
<CreateForm
onCreated={() => {
setShowCreateForm(false);
}}
/>
)}

{/* Loading */}
{isLoading && <p>Loading...</p>}

{/* Posts */}
{posts.map((post) => (
<div key={post.id}>
{editingId === post.id ? (
<InlineEditRow
post={post}
onSaved={() => setEditingId(null)}
onCancel={() => setEditingId(null)}
/>
) : (
<PostRow
post={post}
onEdit={() => setEditingId(post.id)}
/>
)}
</div>
))}

{/* Pagination */}
{pagination && pagination.lastPage > 1 && (
<nav>
<button
onClick={() => setPage((p) => p - 1)}
disabled={pagination.currentPage <= 1}
>
Previous
</button>
<span>
Page {pagination.currentPage} of {pagination.lastPage}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={pagination.currentPage >= pagination.lastPage}
>
Next
</button>
</nav>
)}
</div>
);
}

// ---- Post Row (read-only) ----
function PostRow({ post, onEdit }) {
const deletePost = useModelDelete('posts');

const handleDelete = () => {
if (window.confirm(`Move "${post.title}" to trash?`)) {
deletePost.mutate(post.id);
}
};

return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<strong>{post.title}</strong>
<small> by {post.user?.name}</small>
</div>
<button onClick={onEdit}>Edit</button>
<button onClick={handleDelete} disabled={deletePost.isPending}>
{deletePost.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
);
}

// ---- Inline Edit Row ----
function InlineEditRow({ post, onSaved, onCancel }) {
const updatePost = useModelUpdate('posts');
const [title, setTitle] = useState(post.title);

const handleSave = () => {
updatePost.mutate(
{ id: post.id, data: { title } },
{ onSuccess: () => onSaved() }
);
};

return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ flex: 1 }}
/>
<button onClick={handleSave} disabled={updatePost.isPending}>
{updatePost.isPending ? 'Saving...' : 'Save'}
</button>
<button onClick={onCancel}>Cancel</button>
</div>
);
}

// ---- Create Form ----
function CreateForm({ onCreated }) {
const createPost = useModelStore('posts');
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [errors, setErrors] = useState({});

const handleSubmit = (e) => {
e.preventDefault();
setErrors({});

createPost.mutate(
{ title, body, status: 'draft' },
{
onSuccess: () => {
setTitle('');
setBody('');
onCreated?.();
},
onError: (error) => {
if (error.response?.status === 422) {
setErrors(error.response.data.errors || {});
}
},
}
);
};

return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
{errors.title && <p style={{ color: 'red' }}>{errors.title[0]}</p>}
</div>
<div>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Post body"
rows={4}
/>
{errors.body && <p style={{ color: 'red' }}>{errors.body[0]}</p>}
</div>
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
warning

In the default 'path' tenancy, every CRUD hook needs an active organization. If useOrganization() returns null, query hooks stay idle (no request is made) and mutation hooks throw. Make sure the user has selected an organization -- login() stores the first one it receives. With tenancy: 'subdomain' or 'none' no organization is required. See Authentication for setup details.


Next Steps​

  • Querying -- deep dive into filters, sorts, search, pagination and infinite scroll
  • Utilities -- modelKeys, buildModelUrl and the plain fetchers for code outside React
  • Soft Deletes -- trash, restore, and permanent delete hooks
  • Nested Operations -- atomic multi-model transactions
  • Authentication -- login, logout, and organization context