Skip to main content
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>
  );
}

Pagination Modes

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:
  • Invalidates list queries
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
liveMode: "auto"
manual: Call refetch manually on events
const { refetch } = useList({
  liveMode: "manual",
  onLiveEvent: (event) => {
    if (event.type === "created") {
      refetch();
    }
  },
});
off: No real-time updates (default)
liveMode: "off"

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} />;

3. Use Meta for Custom 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" });
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

Table

Build data tables

Forms

Create and edit forms

React Query

Deep dive into React Query

Build docs developers (and LLMs) love