Skip to main content

Tables

In this step, we'll be learning about the Refine's useTable hook to manage tables in our application.

Implementation Tips:

Refine's useTable has extended versions for UI libraries like Ant Design, Material UI and table libraries like Tanstack Table. To learn more about them, please refer to the Tables guide.

useTable hook is an extended version of the useList hook. It internally manages the search, filters, sorters and pagination for us and also has a built-in integration with the router options to persist the state of the table in the URL.

In this step, we'll be refactoring our <ListProducts /> component to use the useTable hook.

Let's start with mounting our <ListProducts /> in our /src/App.tsx file:

src/App.tsx
import { Refine } from "@refinedev/core";

import { dataProvider } from "./providers/data-provider";

import { ShowProduct } from "./pages/products/show";
import { EditProduct } from "./pages/products/edit";
import { ListProducts } from "./pages/products/list";
import { CreateProduct } from "./pages/products/create";

export default function App(): JSX.Element {
return (
<Refine dataProvider={dataProvider}>
{/* <ShowProduct /> */}
{/* <EditProduct /> */}
<ListProducts />
{/* <CreateProduct /> */}
</Refine>
);
}

Refactoring to use useTable

We'll be using the useTable hook in our <ListProducts /> component and add fields id, name, category, material and price.

Update your src/pages/products/list.tsx file by adding the following lines::

src/pages/products/list.tsx
import { useTable } from "@refinedev/core";

export const ListProducts = () => {
const {
tableQuery: { data, isLoading },
} = useTable({
resource: "products",
pagination: { current: 1, pageSize: 10 },
sorters: { initial: [{ field: "id", order: "asc" }] },
});

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

return (
<div>
<h1>Products</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Material</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{data?.data?.map((product) => (
<tr key={product.id}>
<td>{product.id}</td>
<td>{product.name}</td>
<td>{product.category?.id}</td>
<td>{product.material}</td>
<td>{product.price}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

Handling Relationships

Notice that we're now only displaying the category.id in our table. Similar to the useSelect hook, Refine offers useMany hook that we can use to fetch multiple records with their ids at once.

Let's update our code to use useMany hook to fetch the categories in the table and display the category.title instead of category.id:

src/pages/products/list.tsx
import { useTable, useMany } from "@refinedev/core";

export const ListProducts = () => {
const {
tableQuery: { data, isLoading },
} = useTable({
resource: "products",
pagination: { current: 1, pageSize: 10 },
sorters: { initial: [{ field: "id", order: "asc" }] },
});

const { data: categories } = useMany({
resource: "categories",
ids: data?.data?.map((product) => product.category?.id) ?? [],
});

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

return (
<div>
<h1>Products</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Material</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{data?.data?.map((product) => (
<tr key={product.id}>
<td>{product.id}</td>
<td>{product.name}</td>
<td>
{
categories?.data?.find(
(category) => category.id == product.category?.id,
)?.title
}
</td>
<td>{product.material}</td>
<td>{product.price}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

Adding getMany to the Data Provider

We're now fetching the categories in our <ListProducts /> component. However, we're fetching them one-by-one using the getOne method. We can implement the getMany method in our data provider to fetch multiple records at once.

Implementation Tips:

If getMany method is not implemented in the data provider, Refine will automatically fetch the records one-by-one using the getOne method.

Our fake API supports fetching multiple records at once by passing multiple ids to the url like; /products?id=1&id=2&id=3. Let's add the getMany method to our data provider:

src/providers/data-provider.ts
import type { DataProvider } from "@refinedev/core";

const API_URL = "https://api.fake-rest.refine.dev";

export const dataProvider: DataProvider = {
getMany: async ({ resource, ids, meta }) => {
const params = new URLSearchParams();

if (ids) {
ids.forEach((id) => params.append("id", id));
}

const response = await fetch(`${API_URL}/${resource}?${params.toString()}`);

if (response.status < 200 || response.status > 299) throw response;

const data = await response.json();

return { data };
},
getOne: async ({ resource, id, meta }) => {
const response = await fetch(`${API_URL}/${resource}/${id}`);

if (response.status < 200 || response.status > 299) throw response;

const data = await response.json();

return { data };
},
create: async ({ resource, variables }) => {
/* ... */
},
update: async ({ resource, id, variables }) => {
/* ... */
},
getList: async ({ resource, pagination, filters, sorters, meta }) => {
/* ... */
},
/* ... */
};

Now our useMany method will be able to fetch the categories in a single request and prevent us from bloating our network.

Adding total to the Data Provider

In order to make the pagination work properly, we need to return a proper total value from the getList method in our data provider.

Our fake API sends the total number of records in the X-Total-Count header.

Let's update our getList method to return the total value:

src/providers/data-provider.ts
import type { DataProvider } from "@refinedev/core";

const API_URL = "https://api.fake-rest.refine.dev";

export const dataProvider: DataProvider = {
getList: async ({ resource, pagination, filters, sorters, meta }) => {
const params = new URLSearchParams();

if (pagination) {
params.append("_start", (pagination.current - 1) * pagination.pageSize);
params.append("_end", pagination.current * pagination.pageSize);
}

if (sorters && sorters.length > 0) {
params.append("_sort", sorters.map((sorter) => sorter.field).join(","));
params.append("_order", sorters.map((sorter) => sorter.order).join(","));
}

if (filters && filters.length > 0) {
filters.forEach((filter) => {
if ("field" in filter && filter.operator === "eq") {
// Our fake API supports "eq" operator by simply appending the field name and value to the query string.
params.append(filter.field, filter.value);
}
});
}

const response = await fetch(`${API_URL}/${resource}?${params.toString()}`);

if (response.status < 200 || response.status > 299) throw response;

const data = await response.json();

const total = Number(response.headers.get("x-total-count"));

return {
data,
total,
};
},
getMany: async ({ resource, ids, meta }) => {
/* ... */
},
getOne: async ({ resource, id, meta }) => {
/* ... */
},
create: async ({ resource, variables }) => {
/* ... */
},
update: async ({ resource, id, variables }) => {
/* ... */
},
/* ... */
};

Adding Pagination to the Table

Now we're ready to add pagination to our table. By using the total, Refine's useTable will calculate the pageCount values for us.

We'll use the current, setCurrent and pageCount values from the useTable's response to implement the pagination.

Let's update our <ListProducts /> component to display a simple pagination under the table:

src/pages/products/list.tsx
import { useTable, useMany } from "@refinedev/core";

export const ListProducts = () => {
const {
tableQuery: { data, isLoading },
current,
setCurrent,
pageCount,
} = useTable({
resource: "products",
pagination: { current: 1, pageSize: 10 },
sorters: { initial: [{ field: "id", order: "asc" }] },
});

const { data: categories } = useMany({
resource: "categories",
ids: data?.data?.map((product) => product.category?.id) ?? [],
});

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

const onPrevious = () => {
if (current > 1) {
setCurrent(current - 1);
}
};

const onNext = () => {
if (current < pageCount) {
setCurrent(current + 1);
}
};

const onPage = (page: number) => {
setCurrent(page);
};

return (
<div>
<h1>Products</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Material</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{data?.data?.map((product) => (
<tr key={product.id}>
<td>{product.id}</td>
<td>{product.name}</td>
<td>
{
categories?.data?.find(
(category) => category.id == product.category?.id,
)?.title
}
</td>
<td>{product.material}</td>
<td>{product.price}</td>
</tr>
))}
</tbody>
</table>
<div className="pagination">
<button type="button" onClick={onPrevious}>
{"<"}
</button>
<div>
{current - 1 > 0 && (
<span onClick={() => onPage(current - 1)}>{current - 1}</span>
)}
<span className="current">{current}</span>
{current + 1 < pageCount && (
<span onClick={() => onPage(current + 1)}>{current + 1}</span>
)}
</div>
<button type="button" onClick={onNext}>
{">"}
</button>
</div>
</div>
);
};

Now when we change the page, useTable will automatically fetch the new page and update the table.

Adding Sorters to the Table

As the last step, we'll implement sorters in our table which will allow us to sort the table by clicking on the table headers. We'll use the sorters and setSorters values from the useTable's response to implement this.

Let's update our <ListProducts /> component to allow sorting by clicking on the table headers and display a visual indicator for the sorters:

src/pages/products/list.tsx
import { useTable, useMany } from "@refinedev/core";

export const ListProducts = () => {
const {
tableQuery: { data, isLoading },
current,
setCurrent,
pageCount,
sorters,
setSorters,
} = useTable({
resource: "products",
pagination: { current: 1, pageSize: 10 },
sorters: { initial: [{ field: "id", order: "asc" }] },
});

const { data: categories } = useMany({
resource: "categories",
ids: data?.data?.map((product) => product.category?.id) ?? [],
});

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

const onPrevious = () => { /* ... */ };

const onNext = () => { /* ... */ };

const onPage = (page: number) => { /* ... */ };

// We'll use this function to get the current sorter for a field.
const getSorter = (field: string) => {
const sorter = sorters?.find((sorter) => sorter.field === field);

if (sorter) {
return sorter.order;
}
}

// We'll use this function to toggle the sorters when the user clicks on the table headers.
const onSort = (field: string) => {
const sorter = getSorter(field);
setSorters(
sorter === "desc" ? [] : [
{
field,
order: sorter === "asc" ? "desc" : "asc",
},
]
);
}

// We'll use this object to display visual indicators for the sorters.
const indicator = { asc: "⬆️", desc: "⬇️" };

return (
<div>
<h1>Products</h1>
<table>
<thead>
<tr>
<th onClick={() => onSort("id")}>
ID {indicator[getSorter("id")]}
</th>
<th onClick={() => onSort("name")}>
Name {indicator[getSorter("name")]}
</th>
<th>
Category
</th>
<th onClick={() => onSort("material")}>
Material {indicator[getSorter("material")]}
</th>
<th onClick={() => onSort("price")}>
Price {indicator[getSorter("price")]}
</th>
</tr>
</thead>
<tbody>
{data?.data?.map((product) => (/* ... */))}
</tbody>
</table>
<div className="pagination">
{/* ... */}
</div>
</div>
);
};

Summary

In this step, we've learned about the useTable hook and how to use it to manage tables in our application.

It provides many utilities to manage filters, sorters, paginations and also has a built-in integration with the router options to persist the state of the table in the URL.

Notice that the interfaces are almost identical to the useList hook. The only difference is that useTable has the implementations for wider range of use cases.

To learn more about the Tables in Refine, please refer to the Tables guide.

In the next steps, we'll be learning about the Authentication and Routing in Refine.

Was this helpful?
import { useForm, useSelect } from "@refinedev/core";

export const EditProduct = () => {
  const { onFinish, mutation, query } = useForm({
    action: "edit",
    resource: "products",
    id: "123"
  });

  const record = query.data?.data;

  const { options } = useSelect({
    resource: "categories",
  });

  const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // Using FormData to get the form values and convert it to an object.
    const data = Object.fromEntries(new FormData(event.target).entries());
    // Calling onFinish to submit with the data we've collected from the form.
    onFinish({
      ...data,
      price: Number(data.price).toFixed(2),
      category: { id: Number(data.category) },
    });
  };

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" name="name" defaultValue={record?.name} />

      <label htmlFor="description">Description</label>
      <textarea
        id="description"
        name="description"
        defaultValue={record?.description}
      />

      <label htmlFor="price">Price</label>
      <input
        type="text"
        id="price"
        name="price"
        pattern="\d*.?\d*"
        defaultValue={record?.price}
      />

      <label htmlFor="material">Material</label>
      <input
        type="text"
        id="material"
        name="material"
        defaultValue={record?.material}
      />

      <label htmlFor="category">Category</label>
      <select id="category" name="category">
        {options?.map((option) => (
          <option key={option.value} value={option.value}
            selected={record?.category.id == option.value}
            >
            {option.label}
          </option>
        ))}
      </select>

      {mutation.isSuccess && <span>successfully submitted!</span>}
      <button type="submit">Submit</button>
    </form>
  );
};
installing dependencies
installing dependencies