Refine uses TanStack Query (React Query) under the hood for all data management, providing powerful caching, synchronization, and state management capabilities.
Overview
Refine’s data hooks are thin wrappers around React Query that:
- Call your data provider methods
- Generate optimal cache keys
- Handle loading and error states
- Manage optimistic updates
- Invalidate cache on mutations
- Support real-time subscriptions
Query Hooks
useList
Fetch a list of records with pagination, filtering, and sorting.
From packages/core/src/hooks/data/useList.ts:120-142:
import { useList } from "@refinedev/core";
function PostList() {
const { query, result } = useList({
resource: "posts",
pagination: {
current: 1,
pageSize: 10,
mode: "server", // or "client" | "off"
},
filters: [
{
field: "status",
operator: "eq",
value: "published",
},
],
sorters: [
{
field: "createdAt",
order: "desc",
},
],
});
// query: React Query result (isLoading, error, refetch, etc.)
// result: { data: Post[], total: number }
if (query.isLoading) return <div>Loading...</div>;
if (query.error) return <div>Error: {query.error.message}</div>;
return (
<div>
<p>Total: {result.total}</p>
{result.data.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
</div>
))}
</div>
);
}
Server-side pagination (default):
const { result } = useList({
pagination: {
current: 2,
pageSize: 10,
mode: "server", // Sends pagination to API
},
});
// API receives: { _start: 10, _limit: 10 }
Client-side pagination:
const { result } = useList({
pagination: {
current: 2,
pageSize: 10,
mode: "client", // Fetches all, paginates in memory
},
});
// API receives all records, Refine slices in client
No pagination:
const { result } = useList({
pagination: {
mode: "off", // Fetch all records
},
});
Filters
From packages/core/src/contexts/data/types.ts:272-302:
type CrudOperators =
| "eq" // Equal
| "ne" // Not equal
| "lt" // Less than
| "gt" // Greater than
| "lte" // Less than or equal
| "gte" // Greater than or equal
| "in" // In array
| "nin" // Not in array
| "contains" // Contains (case-insensitive)
| "ncontains" // Doesn't contain
| "containss" // Contains (case-sensitive)
| "ncontainss" // Doesn't contain (case-sensitive)
| "null" // Is null
| "nnull" // Is not null
| "startswith" // Starts with
| "nstartswith" // Doesn't start with
| "endswith" // Ends with
| "nendswith" // Doesn't end with
| "between" // Between two values
| "nbetween" // Not between
| "or" // Logical OR
| "and"; // Logical AND
type LogicalFilter = {
field: string;
operator: Exclude<CrudOperators, "or" | "and">;
value: any;
};
type ConditionalFilter = {
key?: string;
operator: "or" | "and";
value: (LogicalFilter | ConditionalFilter)[];
};
Examples:
// Simple filter
filters: [
{ field: "status", operator: "eq", value: "published" },
]
// Multiple filters (AND)
filters: [
{ field: "status", operator: "eq", value: "published" },
{ field: "views", operator: "gte", value: 1000 },
]
// OR condition
filters: [
{
operator: "or",
value: [
{ field: "status", operator: "eq", value: "draft" },
{ field: "status", operator: "eq", value: "published" },
],
},
]
// Complex nested filters
filters: [
{
operator: "and",
value: [
{ field: "category", operator: "eq", value: "tech" },
{
operator: "or",
value: [
{ field: "views", operator: "gte", value: 1000 },
{ field: "featured", operator: "eq", value: true },
],
},
],
},
]
useOne
Fetch a single record.
From packages/core/src/hooks/data/useOne.ts:105-122:
import { useOne } from "@refinedev/core";
function PostShow() {
const { query, result } = useOne({
resource: "posts",
id: 123,
});
if (query.isLoading) return <div>Loading...</div>;
if (!result) return <div>Not found</div>;
return (
<div>
<h1>{result.title}</h1>
<p>{result.content}</p>
</div>
);
}
Auto-Inference from Route
// On route: /posts/edit/123
function PostEdit() {
// ID is automatically inferred from URL
const { result } = useOne({
resource: "posts",
// id is optional here
});
return <Form initialValues={result} />;
}
useMany
Fetch multiple records by IDs:
import { useMany } from "@refinedev/core";
function RelatedPosts({ postIds }) {
const { data } = useMany({
resource: "posts",
ids: postIds, // [1, 2, 3]
});
return (
<div>
{data?.data.map((post) => (
<Card key={post.id}>{post.title}</Card>
))}
</div>
);
}
useMany automatically batches requests if your data provider supports getMany.
useInfiniteList
Infinite scrolling / load more:
import { useInfiniteList } from "@refinedev/core";
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteList({
resource: "posts",
pagination: {
pageSize: 10,
},
});
return (
<div>
{data?.pages.map((page) => (
page.data.map((post) => (
<div key={post.id}>{post.title}</div>
))
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}
useCustom
Custom API calls outside of CRUD:
import { useCustom } from "@refinedev/core";
function Dashboard() {
const { data, isLoading } = useCustom({
url: `${API_URL}/statistics`,
method: "get",
config: {
headers: {
"X-Custom-Header": "value",
},
},
});
return (
<div>
<h1>Statistics</h1>
<p>Total Users: {data?.data.totalUsers}</p>
<p>Total Posts: {data?.data.totalPosts}</p>
</div>
);
}
Mutation Hooks
useCreate
Create a new record.
From packages/core/src/hooks/data/useCreate.ts:91-100:
import { useCreate } from "@refinedev/core";
function PostCreate() {
const { mutate, isLoading } = useCreate();
const handleSubmit = (values) => {
mutate(
{
resource: "posts",
values: {
title: values.title,
content: values.content,
},
},
{
onSuccess: (data) => {
console.log("Created:", data);
},
onError: (error) => {
console.error("Error:", error);
},
},
);
};
return (
<Form onSubmit={handleSubmit}>
<Input name="title" />
<Textarea name="content" />
<Button type="submit" loading={isLoading}>
Create
</Button>
</Form>
);
}
useUpdate
Update an existing record:
import { useUpdate } from "@refinedev/core";
function PostEdit({ id, initialValues }) {
const { mutate, isLoading } = useUpdate();
const handleSubmit = (values) => {
mutate({
resource: "posts",
id,
values,
});
};
return <Form initialValues={initialValues} onSubmit={handleSubmit} />;
}
useDelete
Delete a record:
import { useDelete } from "@refinedev/core";
function DeleteButton({ id }) {
const { mutate, isLoading } = useDelete();
const handleDelete = () => {
if (confirm("Are you sure?")) {
mutate({
resource: "posts",
id,
});
}
};
return (
<button onClick={handleDelete} disabled={isLoading}>
Delete
</button>
);
}
Batch Mutations
import { useCreateMany, useUpdateMany, useDeleteMany } from "@refinedev/core";
// Create multiple
const { mutate: createMany } = useCreateMany();
createMany({
resource: "posts",
values: [
{ title: "Post 1" },
{ title: "Post 2" },
],
});
// Update multiple
const { mutate: updateMany } = useUpdateMany();
updateMany({
resource: "posts",
ids: [1, 2, 3],
values: { status: "published" },
});
// Delete multiple
const { mutate: deleteMany } = useDeleteMany();
deleteMany({
resource: "posts",
ids: [1, 2, 3],
});
Mutation Modes
Pessimistic (Default)
Wait for server response before updating UI:
<Refine options={{ mutationMode: "pessimistic" }} />
Optimistic
Update UI immediately, rollback on error:
<Refine options={{ mutationMode: "optimistic" }} />
const { mutate } = useUpdate();
mutate(
{ resource: "posts", id: 1, values: { title: "New Title" } },
{
// Optimistic update
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(["posts", "detail", 1]);
// Snapshot previous value
const previousPost = queryClient.getQueryData(["posts", "detail", 1]);
// Optimistically update
queryClient.setQueryData(["posts", "detail", 1], (old) => ({
...old,
data: { ...old.data, ...variables.values },
}));
return { previousPost };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(
["posts", "detail", 1],
context.previousPost,
);
},
},
);
Undoable
Update UI immediately with undo option:
<Refine
options={{
mutationMode: "undoable",
undoableTimeout: 5000, // 5 seconds to undo
}}
/>
import { useDelete } from "@refinedev/core";
function DeleteButton({ id }) {
const { mutate } = useDelete();
// Mutation is delayed by undoableTimeout
// Shows notification with Undo button
mutate({ resource: "posts", id });
}
Use optimistic for better perceived performance, undoable for destructive actions, and pessimistic when you need guaranteed server confirmation.
Cache Invalidation
Refine automatically invalidates related queries on mutations:
import { useInvalidate } from "@refinedev/core";
function MyComponent() {
const invalidate = useInvalidate();
const handleRefresh = () => {
// Invalidate all posts queries
invalidate({
resource: "posts",
invalidates: ["list", "many", "detail"],
});
};
const handleInvalidateOne = () => {
// Invalidate single post
invalidate({
resource: "posts",
id: 123,
invalidates: ["detail"],
});
};
return (
<div>
<button onClick={handleRefresh}>Refresh All</button>
<button onClick={handleInvalidateOne}>Refresh Post #123</button>
</div>
);
}
Automatic Invalidation
On useCreate:
On useUpdate:
- Invalidates
list, many, and detail queries
On useDelete:
- Invalidates
list and many queries
Custom Invalidation
const { mutate } = useCreate();
mutate(
{
resource: "posts",
values: { title: "New Post" },
invalidates: ["list"], // Only invalidate list
},
);
Real-Time Updates
Subscribe to real-time updates with Live Provider:
import { useList } from "@refinedev/core";
function PostList() {
const { data } = useList({
resource: "posts",
liveMode: "auto", // or "manual" | "off"
});
// Automatically refetches when real-time events occur
return <Table dataSource={data} />;
}
Live Modes
auto: Automatically refetch on events
manual: Call refetch manually on events
const { refetch } = useList({
liveMode: "manual",
onLiveEvent: (event) => {
if (event.type === "created") {
refetch();
}
},
});
off: No real-time updates (default)
React Query Integration
Access Query Client
import { useQueryClient } from "@tanstack/react-query";
function MyComponent() {
const queryClient = useQueryClient();
const handleRefresh = () => {
// Invalidate all queries
queryClient.invalidateQueries();
};
const handlePrefetch = () => {
// Prefetch a query
queryClient.prefetchQuery(["posts", "list"], fetchPosts);
};
}
Custom Query Options
Pass React Query options to hooks:
const { data } = useList({
resource: "posts",
queryOptions: {
retry: 3,
retryDelay: 1000,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
enabled: someCondition,
onSuccess: (data) => {
console.log("Success:", data);
},
onError: (error) => {
console.error("Error:", error);
},
},
});
Dependent Queries
function PostWithAuthor({ postId }) {
const { data: post } = useOne({
resource: "posts",
id: postId,
});
const { data: author } = useOne({
resource: "users",
id: post?.data?.authorId,
queryOptions: {
enabled: !!post?.data?.authorId, // Only fetch when we have authorId
},
});
return (
<div>
<h1>{post?.data?.title}</h1>
<p>By: {author?.data?.name}</p>
</div>
);
}
Best Practices
1. Use Type Safety
import { BaseRecord, useList } from "@refinedev/core";
interface IPost extends BaseRecord {
id: number;
title: string;
content: string;
status: "draft" | "published";
}
function PostList() {
const { result } = useList<IPost>({
resource: "posts",
});
// result.data is typed as IPost[]
return result.data.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<span>{post.status}</span>
</div>
));
}
2. Handle Loading States
const { query, result } = useList({ resource: "posts" });
if (query.isLoading) return <Skeleton />;
if (query.error) return <ErrorMessage error={query.error} />;
if (!result.data.length) return <EmptyState />;
return <Table dataSource={result.data} />;
const { data } = useList({
resource: "posts",
meta: {
fields: ["id", "title", "author.name"], // GraphQL fields
headers: { "X-Custom-Header": "value" },
},
});
4. Optimize Queries
// Good - Only fetch needed fields
const { data } = useList({
resource: "posts",
meta: {
select: ["id", "title"],
},
});
// Bad - Fetching all fields when only need title
const { data } = useList({ resource: "posts" });
5. Debounce Search
import { useList } from "@refinedev/core";
import { useDebouncedValue } from "@refinedev/core";
function SearchablePosts() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 500);
const { data } = useList({
resource: "posts",
filters: [
{
field: "title",
operator: "contains",
value: debouncedSearch,
},
],
});
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search posts..."
/>
<PostList data={data} />
</div>
);
}
Next Steps
Providers
Learn about data providers
Forms
Create and edit forms
React Query
Deep dive into React Query