Tables
In this step, we'll be learning about the Refine's useTable
hook to manage tables in our application.
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:
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::
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
:
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.
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:
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:
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:
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:
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.
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> ); };