Skip to main content

Querying

This is for the @rhino-dev/rhino-react React client library. All query options work with useModelIndex, useModelInfinite, useModelShow, and useModelTrashed.

ModelQueryOptions describes what to fetch -- it becomes the URL and the cache key. How to fetch it -- polling, enabled, select, staleTime -- goes in the hook's separate trailing queryOptions argument, covered in CRUD Hooks -- TanStack Query Options.

Query Options​

Every query hook accepts a ModelQueryOptions object that controls filtering, sorting, pagination, search, eager loading, and field selection:

src/types.ts
interface ModelQueryOptions {
filters?: Record<string, any>;
includes?: string[];
sort?: string;
fields?: string[];
search?: string;
scope?: string | ScopeSelection;
computedAttributes?: string[] | ComputedAttributeSelection;
page?: number;
perPage?: number;
}
info

All query parameters are optional. You can combine any subset of them in a single request.

Filtering​

Pass a filters object to narrow results. Each key-value pair maps to a query parameter the server uses to scope the response.

src/components/PostsList.tsx
const { data } = useModelIndex('posts', {
filters: { status: 'published' },
});

// Multiple filters (AND)
const { data } = useModelIndex('posts', {
filters: { status: 'published', user_id: 1 },
});

Maps to: GET /api/posts?filter[status]=published&filter[user_id]=1

tip

Filters are reactive. When the filter values change, TanStack Query automatically refetches with the new parameters.

Dynamic Filters Example​

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

function FilterablePosts() {
const [status, setStatus] = useState('all');
const [category, setCategory] = useState('');

const filters: Record<string, any> = {};
if (status !== 'all') filters.status = status;
if (category) filters.category_id = category;

const { data: response, isLoading } = useModelIndex('posts', {
filters,
sort: '-created_at',
perPage: 20,
});

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

return (
<div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1rem' }}>
<select value={status} onChange={e => setStatus(e.target.value)}>
<option value="all">All Statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>

<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="">All Categories</option>
<option value="1">Technology</option>
<option value="2">Design</option>
<option value="3">Business</option>
</select>
</div>

{isLoading ? (
<p>Loading...</p>
) : (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title} — <em>{post.status}</em>
</li>
))}
</ul>
)}
</div>
);
}

Sorting​

Use the sort option to order results. Prefix a field name with - for descending order.

src/components/PostsList.tsx
// Ascending by title
const { data } = useModelIndex('posts', { sort: 'title' });

// Descending by date (newest first)
const { data } = useModelIndex('posts', { sort: '-created_at' });

// Multiple sorts (comma-separated)
const { data } = useModelIndex('posts', { sort: '-created_at,title' });
info

Multiple sort fields are comma-separated in a single string. The server applies them in order -- the first field is the primary sort, the second breaks ties, and so on.

Sortable Table Header Example​

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

function SortablePostsTable() {
const [sortField, setSortField] = useState('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');

const sort = sortDirection === 'desc' ? `-${sortField}` : sortField;

const { data: response, isLoading } = useModelIndex('posts', {
sort,
perPage: 20,
});

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

const toggleSort = (field: string) => {
if (sortField === field) {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};

const SortHeader = ({ field, label }: { field: string; label: string }) => (
<th
onClick={() => toggleSort(field)}
style={{ cursor: 'pointer', userSelect: 'none' }}
>
{label}{' '}
{sortField === field ? (sortDirection === 'asc' ? '▲' : '▼') : ''}
</th>
);

if (isLoading) return <p>Loading...</p>;

return (
<table>
<thead>
<tr>
<SortHeader field="title" label="Title" />
<SortHeader field="status" label="Status" />
<SortHeader field="created_at" label="Created" />
</tr>
</thead>
<tbody>
{posts.map(post => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{post.status}</td>
<td>{new Date(post.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
);
}

Use the search option for full-text search across server-defined searchable fields.

src/components/PostsList.tsx
const { data } = useModelIndex('posts', { search: 'laravel' });
tip

The fields that are searched depend on your Laravel backend configuration. Typically, this covers fields like title, body, or any fields you have registered as searchable on the model.

Debounced Search Example​

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

function SearchablePosts() {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');

useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);

const { data: response, isLoading } = useModelIndex('posts', {
search: debouncedSearch,
perPage: 20,
});

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

return (
<div>
<input
type="text"
placeholder="Search posts..."
value={search}
onChange={e => setSearch(e.target.value)}
/>

{isLoading ? (
<p>Searching...</p>
) : (
<>
<p>{response?.pagination?.total ?? 0} results found</p>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</>
)}
</div>
);
}

Named Scopes​

Use the scope option to select a server-defined named scope by name. The backend declares which scopes a model exposes (and a default); the client just picks one. This is ideal for complex, user-specific listings whose joins are resolved server-side — you send a name, not the query.

src/components/DriverRoutes.tsx
// Only the routes this authenticated driver may take
const { data } = useModelIndex('routes', { scope: 'availableForDrivers' });

Maps to: GET /api/routes?scope=availableForDrivers

When you omit scope, the server applies the model's declared default scope automatically. Named scopes compose with filters, sort, page, perPage, search, includes, and fields:

src/components/DriverRoutes.tsx
const { data } = useModelIndex('routes', {
scope: 'availableForDrivers',
sort: '-created_at',
includes: ['region'],
page,
perPage: 20,
});

It also works with useModelTrashed:

src/components/ActiveTrash.tsx
const { data } = useModelTrashed('routes', { scope: 'active' });

Scopes with arguments​

A scope can take arguments the server declares. Pass an object instead of a name: the key is the scope, and the value is its argument.

// One declared parameter: a bare value
useModelIndex('routes', { scope: { since: '2026-01-01' } });
// GET /api/routes?scope[since]=2026-01-01

// Several: an object of parameter name to value
useModelIndex('routes', { scope: { window: { from: '2026-01-01', to: '2026-02-01' } } });
// GET /api/routes?scope[window][from]=2026-01-01&scope[window][to]=2026-02-01

// A scope that takes no arguments, written in the object form
useModelIndex('routes', { scope: { archived: null } });
// GET /api/routes?scope[archived]=

Up to three scopes may be combined, and they apply in key order:

useModelIndex('routes', {
scope: { archived: null, since: '2026-01-01' },
});

Use the object form for every scope in a request that needs arguments: the string form and the object form cannot be mixed, because they share the one scope query key. A plain scope: 'archived' is still the way to select a single no-argument scope.

Arguments the server did not declare, a missing required one, or a bare value for a scope with several parameters all return 403 with a message naming the parameter.

Not applied by useModelShow

scope narrows list results, so it applies to useModelIndex and useModelTrashed. It is not applied by useModelShow (single-record fetches are not scoped).

Unknown scopes return 403

A scope name the model has not whitelisted, or that the user's policy does not permit, causes the request to 403. Both cases return the same message. This surfaces through the client's onForbidden path — handle it like any other authorization failure.

Filtering or sorting by a hidden attribute returns 403

An attribute the server's policy hides from this user cannot be used in filters or sort. A column the model never allowlisted is still ignored silently, as before; it is only the hidden ones that refuse.

Worked Example: driver routes​

The backend declares an availableForDrivers named scope on the Route model — a scope whose body joins assignments and driver qualifications and filters to the authenticated driver, all resolved server-side. The drivers app then only has to ask for it by name:

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

function AvailableRoutes() {
const { data: response, isLoading } = useModelIndex('routes', {
scope: 'availableForDrivers',
sort: '-created_at',
includes: ['region'],
});

const routes = response?.data || [];

if (isLoading) return <p>Loading routes...</p>;

return (
<ul>
{routes.map(route => (
<li key={route.id}>
{route.name} — {route.region?.name}
</li>
))}
</ul>
);
}

The client sends only ?scope=availableForDrivers; the server resolves the current user and applies the complex joins, returning exactly the routes that driver may take. The client never sends user identity.

Computed Attributes​

Servers can expose values that aren't database columns. Two of the three kinds are opt-in, so you only pay for what you ask for.

Per-record attributes — computedAttributes​

Pass the attribute names you want merged into each returned record. Nothing is computed server-side unless you name it:

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

function UserList() {
const { data: response } = useModelIndex('users', {
computedAttributes: ['full_name', 'avatar_url'],
});

return (
<ul>
{(response?.data ?? []).map(user => (
<li key={user.id}>
<img src={user.avatar_url} alt="" />
{user.full_name}
</li>
))}
</ul>
);
}

Works on useModelIndex, useModelShow and useModelTrashed. Serialized as ?computed_attributes=full_name,avatar_url.

Requesting an attribute the model doesn't declare — or that your role isn't allowed to read — returns 403, so a typo surfaces immediately rather than silently returning nothing.

Attributes with arguments​

An attribute can declare parameters the server fills in from the client. Pass an object instead of an array: the key is the attribute, and the value is its argument.

// One declared parameter: a bare value
useModelIndex('users', { computedAttributes: { ticketsSince: '2026-01-01' } });
// GET /api/users?computed_attributes[ticketsSince]=2026-01-01

// Several: an object of parameter name to value
useModelIndex('users', { computedAttributes: { revenue: { from: '2026-01-01', to: '2026-02-01' } } });
// GET /api/users?computed_attributes[revenue][from]=2026-01-01&computed_attributes[revenue][to]=2026-02-01

// An attribute that takes no arguments, written in the object form
useModelIndex('users', { computedAttributes: { avatar_url: null } });
// GET /api/users?computed_attributes[avatar_url]=

The forms may be combined freely within one object, and they apply in key order:

useModelIndex('users', {
computedAttributes: {
avatar_url: null,
ticketsSince: '2026-01-01',
revenue: { from: '2026-01-01', to: '2026-02-01' },
},
});

Use the object form for every attribute in a request that needs arguments: the array form and the object form cannot be mixed, because they share the one computed_attributes query key. A plain computedAttributes: ['avatar_url'] is still the way to select attributes that take none.

Booleans serialize as true / false and reach the server as real booleans, so a flag parameter behaves the way you would expect. Arguments the server did not declare, a missing required one, or a bare value for an attribute with several parameters all return 403 with a message naming the parameter.

ComputedAttributeSelection
type ComputedAttributeSelection = Record<
string,
string | number | boolean | null | Record<string, string | number | boolean>
>;

Collection aggregates — useModelComputedAttributes​

For counts, sums and averages over the whole collection, use the dedicated hook. Each attribute is evaluated once per request on the server rather than once per row, which is what makes it cheap:

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

function UserStats() {
const { data: stats, isLoading } = useModelComputedAttributes('users', {
attributes: ['active_users_count', 'blocked_users_count'],
});

if (isLoading) return <Spinner />;

return (
<dl>
<dt>Active</dt><dd>{stats?.active_users_count}</dd>
<dt>Blocked</dt><dd>{stats?.blocked_users_count}</dd>
</dl>
);
}

The hook returns the attribute object itself — { active_users_count: 128, blocked_users_count: 4 }. Omit attributes to fetch every attribute your role is allowed to read, minus any that declares a required parameter — those are skipped rather than erroring, so adding one server-side never breaks a client that asks for everything.

attributes takes the same object form as computedAttributes, for aggregates that declare parameters:

const { data: stats } = useModelComputedAttributes('users', {
attributes: {
revenue: { from: '2026-01-01', to: '2026-02-01' },
activeUsersCount: null,
},
});
// GET /api/users/computed?attributes[revenue][from]=2026-01-01
// &attributes[revenue][to]=2026-02-01&attributes[activeUsersCount]=

filters, search and scope narrow the set the aggregates describe, exactly as they narrow useModelIndex. Pass the same values to both hooks and the numbers describe the list the user is actually looking at:

src/components/FilteredUsers.tsx
function FilteredUsers({ teamId, term }) {
const query = { filters: { team_id: teamId }, search: term };

const { data: users } = useModelIndex('users', query);
const { data: stats } = useModelComputedAttributes('users', {
...query,
attributes: ['active_users_count'],
});

return (
<>
<h2>{stats?.active_users_count} active in this team</h2>
<UserTable rows={users?.data ?? []} />
</>
);
}
ComputedAttributesOptions
interface ComputedAttributesOptions {
attributes?: string[] | ComputedAttributeSelection;
filters?: Record<string, any>;
search?: string;
scope?: string;
}

ComputedAttributeSelection and ScopeSelection are both exported from the package entry point, so you can type a selection you build up in state.

Don't compute counts per row

If you find yourself declaring a count as a per-record computed attribute, move it to a collection attribute — the server would otherwise run the same query once for every row on the page.

Pagination​

Control page-based pagination with page and perPage. The response includes a pagination object with metadata.

src/components/PaginatedPosts.tsx
const [page, setPage] = useState(1);
const { data: response } = useModelIndex('posts', { page, perPage: 20 });

const pagination = response?.pagination;
// { currentPage: 1, lastPage: 10, perPage: 20, total: 195 }
info

Pagination metadata is extracted from response headers, not the response body. The hooks handle this automatically and expose it as response.pagination.

Pagination Component Example​

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

function PaginatedPosts() {
const [page, setPage] = useState(1);
const perPage = 20;

const { data: response, isLoading } = useModelIndex('posts', {
page,
perPage,
sort: '-created_at',
});

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

if (isLoading) return <p>Loading...</p>;

return (
<div>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>

{pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<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 records)
</span>

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

Infinite Scroll — useModelInfinite​

For a "load more" button or an infinite-scroll list, use useModelInfinite. It requests the same URL as useModelIndex, one page at a time, and keeps every page it has loaded:

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

function PostFeed() {
const { data, pagination, fetchNextPage, hasNextPage, isFetchingNextPage } =
useModelInfinite<Post>('posts', { sort: '-created_at', perPage: 20 });

const posts = data?.pages.flatMap((page) => page.data) ?? [];

return (
<>
<ul>
{posts.map((post) => <li key={post.id}>{post.title}</li>)}
</ul>
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : `Load more (${posts.length} of ${pagination?.total})`}
</button>
)}
</>
);
}
GET /api/{organization}/posts?sort=-created_at&page=1&per_page=20
GET /api/{organization}/posts?sort=-created_at&page=2&per_page=20 # after fetchNextPage()

useModelInfinite<T>(model, options?, queryOptions?) is built on TanStack Query's useInfiniteQuery:

BehaviorDetail
Next pageRead from the pagination headers of the last loaded page: currentPage + 1 while currentPage < lastPage.
Last pagehasNextPage is false once currentPage reaches lastPage, and when the response carries no pagination headers.
options.pageIgnored -- the hook drives the page. perPage (or per_page) is respected.
dataInfiniteData<QueryResponse<T>>: data.pages holds one { data, pagination } per loaded page. Flatten with data.pages.flatMap((p) => p.data).
paginationAdded to the standard infinite-query result: the pagination of the most recently loaded page, or null before one has loaded. A select in queryOptions does not affect it.
queryOptionsModelInfiniteQueryHookOptions -- useInfiniteQuery options without queryKey, queryFn, initialPageParam, getNextPageParam and getPreviousPageParam. enabled is AND-ed with the organization guard, as on every query hook.
Cache keymodelKeys.infinite(model, options), separate from the useModelIndex key.
InvalidationEvery mutation that invalidates a model's useModelIndex lists also invalidates its infinite lists.

On React Native, wire fetchNextPage to FlatList's onEndReached:

src/screens/PostFeedScreen.tsx
import { FlatList, ActivityIndicator, Text } from 'react-native';
import { useModelInfinite } from '@rhino-dev/rhino-react';

function PostFeedScreen() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isRefetching } =
useModelInfinite<Post>('posts', { sort: '-created_at', perPage: 20 });

return (
<FlatList
data={data?.pages.flatMap((page) => page.data) ?? []}
keyExtractor={(post) => String(post.id)}
renderItem={({ item }) => <Text>{item.title}</Text>}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.5}
onRefresh={refetch}
refreshing={isRefetching}
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
/>
);
}
Which list hook?

Use useModelIndex when the user picks a page (a table with Previous / Next) and useModelInfinite when pages append to what is already on screen (a feed, a mobile list).

Eager Loading (Includes)​

Use includes to load related models in a single request, avoiding N+1 query problems.

src/components/PostsList.tsx
const { data } = useModelIndex('posts', {
includes: ['user', 'comments', 'tags'],
});

// Access included data
response?.data.forEach(post => {
console.log(post.user.name);
console.log(post.comments.length);
});
tip

Only include relationships you actually need. Each included relationship adds to the response payload size and server query time.

This works the same way with useModelShow:

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

// Nested includes use dot notation
// post.comments[0].user.name

Field Selection​

Use fields to request only specific fields, reducing payload size.

src/components/PostsList.tsx
const { data } = useModelIndex('posts', {
fields: ['id', 'title', 'status'],
});
tip

Field selection is useful for list views where you only need a few columns. Fetching fewer fields reduces bandwidth and can improve response times.

Combined Example​

Here is a complete component that brings together filtering, sorting, search, pagination, eager loading, and field selection:

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

function AdvancedPostsList() {
// Search with debounce
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');

useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);

// Filters
const [status, setStatus] = useState('all');

// Sorting
const [sortField, setSortField] = useState('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');

// Pagination
const [page, setPage] = useState(1);
const perPage = 20;

// Reset to page 1 when filters or search change
useEffect(() => {
setPage(1);
}, [status, debouncedSearch, sortField, sortDirection]);

// Build filters
const filters: Record<string, any> = {};
if (status !== 'all') filters.status = status;

// Build sort string
const sort = sortDirection === 'desc' ? `-${sortField}` : sortField;

// Query
const { data: response, isLoading } = useModelIndex('posts', {
filters,
sort,
search: debouncedSearch,
page,
perPage,
includes: ['user', 'tags'],
fields: ['id', 'title', 'status', 'created_at'],
});

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

const toggleSort = (field: string) => {
if (sortField === field) {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};

return (
<div>
{/* Toolbar */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1rem' }}>
<input
type="text"
placeholder="Search posts..."
value={search}
onChange={e => setSearch(e.target.value)}
/>

<select value={status} onChange={e => setStatus(e.target.value)}>
<option value="all">All Statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
</div>

{/* Table */}
{isLoading ? (
<p>Loading...</p>
) : (
<table>
<thead>
<tr>
<th
onClick={() => toggleSort('title')}
style={{ cursor: 'pointer' }}
>
Title {sortField === 'title' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}
</th>
<th>Author</th>
<th>Tags</th>
<th
onClick={() => toggleSort('status')}
style={{ cursor: 'pointer' }}
>
Status {sortField === 'status' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}
</th>
<th
onClick={() => toggleSort('created_at')}
style={{ cursor: 'pointer' }}
>
Created {sortField === 'created_at' ? (sortDirection === 'asc' ? '▲' : '▼') : ''}
</th>
</tr>
</thead>
<tbody>
{posts.map(post => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{post.user?.name}</td>
<td>{post.tags?.map(t => t.name).join(', ')}</td>
<td>{post.status}</td>
<td>{new Date(post.created_at).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
)}

{/* Pagination */}
{pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '1rem' }}>
<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>
</div>
)}
</div>
);
}
tip

Notice the useEffect that resets the page to 1 whenever filters, search, or sort change. This prevents the user from being stuck on an out-of-range page after narrowing results.

  • CRUD Hooks -- hook signatures, TanStack Query options, file uploads and cache invalidation
  • Soft Deletes -- useModelTrashed takes the same query options
  • Utilities -- buildModelUrl, modelKeys and the plain fetchers behind these hooks