Data & Export
Exporting data, fetching options, and parameter persistence.
Exported Data
Among the default list actions, shadmin includes an <ExportButton>.
By default, clicking this button will:
- Call the
dataProviderwith the current sort and filter (but without pagination), - Transform the result into a CSV string,
- Download the CSV file.
The columns of the CSV file match all the fields of the records in the dataProvider response. That means that the export doesn't take into account the selection and ordering of fields in your <List> via Field components. If you want to customize the result, pass a custom exporter function to the <List>. This function will receive the data from the dataProvider (after step 1) and replace steps 2-3 (i.e. it's in charge of transforming, converting, and downloading the file).
In many cases, you'll need more than simple object manipulation. You'll need to augment your objects based on relationships. For instance, the export for comments should include the title of the related post - but the export only exposes a post_id by default. For that purpose, the exporter receives a fetchRelatedRecords function as the second parameter. It fetches related records using your dataProvider.getMany() method and returns a promise.
Here is an example for a Comments exporter, fetching related Posts:
// in CommentList.js
import { List } from "@/components/admin";
import { downloadCSV, type FetchRelatedRecords } from "ra-core";
import jsonExport from "jsonexport/dist";
const exporter = async (
comments: Comments[],
fetchRelatedRecords: FetchRelatedRecords,
) => {
// will call dataProvider.getMany('posts', { ids: records.map(record => record.post_id) }),
// ignoring duplicate and empty post_id
const posts = await fetchRelatedRecords<Post>(comments, "post_id", "posts");
const commentsWithPostTitle = comments.map((comment) => ({
...comment,
post_title: posts[comment.post_id].title,
}));
return jsonExport(
commentsWithPostTitle,
{
headers: ["id", "post_id", "post_title", "body"],
},
(err, csv) => {
downloadCSV(csv, "comments");
},
);
};
const CommentList = () => <List exporter={exporter}>...</List>;Data Fetching Options
<List> accepts a queryOptions prop to pass query options to the react-query client. Check react-query's useQuery documentation for the list of available options.
This can be useful e.g. to pass a custom meta to the dataProvider.getList() call.
import { List } from "@/components/admin";
const PostList = () => <List queryOptions={{ meta: { foo: "bar" } }}>...</List>;With this option, shadmin will call dataProvider.getList() on mount with the meta: { foo: 'bar' } option.
You can also use the queryOptions prop to override the default error side effect. By default, when the dataProvider.getList() call fails, shadmin shows an error notification. Here is how to show a custom notification instead:
import { useNotify, useRedirect, List } from "@/components/admin";
const PostList = () => {
const notify = useNotify();
const redirect = useRedirect();
const onError = (error) => {
notify(`Could not load list: ${error.message}`, { type: "error" });
redirect("/dashboard");
};
return <List queryOptions={{ onError }}>...</List>;
};The onError function receives the error from the dataProvider call (dataProvider.getList()), which is a JavaScript Error object (see the dataProvider documentation for details).
If dataProvider.getList() returns additional metadata in the response under the meta key, you can access it in the list view using the meta property of the ListContext.

This is often used by APIs to return facets, aggregations, statistics, or other metadata about the list of records.
// dataProvider.getLists('books') returns response like
// {
// data: [ ... ],
// total: 293,
// meta: {
// genres: [
// { value: 'Fictions', count: 134 },
// { value: 'Essays', count: 24 },
// ],
// centuries: [
// { value: '18th', count: 23 },
// { value: '19th', count: 78 },
// { value: '20th', count: 57 },
// { value: '21st', count: 34 },
// ],
// },
// }
const Facets = () => {
const { isPending, error, meta } = useListContext();
if (isPending || error) return null;
return (
<div className="space-y-4 rounded-md border p-4 bg-background">
<div>
<h4 className="text-sm font-semibold tracking-wide text-foreground/80 mb-2">
Genres
</h4>
<ul className="space-y-1 text-sm">
{meta.genres.map((facet) => (
<li key={facet.value}>
<Link href="#" className="hover:underline">
{facet.value}{" "}
<span className="text-muted-foreground">({facet.count})</span>
</Link>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-semibold tracking-wide text-foreground/80 mb-2">
Century
</h4>
<ul className="space-y-1 text-sm">
{meta.centuries.map((facet) => (
<li key={facet.value}>
<Link href="#" className="hover:underline">
{facet.value}{" "}
<span className="text-muted-foreground">({facet.count})</span>
</Link>
</li>
))}
</ul>
</div>
</div>
);
};You might want to allow data to be fetched only when at least some filters have been set. You can leverage TanStack react-query enabled option for that. It accepts a function that receives the query as its only parameter. As shadmin always format the queryKey as [ResourceName, DataProviderMethod, DataProviderParams], you can check that there is at least a filter in this function:
export const PostList = () => (
<List
filters={postFilter}
queryOptions={{
enabled: (query) => {
const listParams = query.queryKey[2] as GetListParams;
return listParams.filter.q?.length > 2;
},
}}
render={(context) =>
context.filterValues.q?.length > 2 ? (
<CardContentInner>Type a search term to fetch data</CardContentInner>
) : (
<Datagrid>{/* your fields */}</Datagrid>
)
}
/>
);Parameters Persistence
By default, when users change the list parameters (sort, pagination, filters), shadmin stores them in localStorage so that users can come back to the list and find it in the same state as when they left it, using the internal Store.
Shadmin uses the current resource as the identifier to store the list parameters (under the key ${resource}.listParams).
If you want to display multiple lists of the same resource and keep distinct store states for each of them (filters, sorting and pagination), you must give each list a unique storeKey property. You can also disable the persistence of list parameters and selection in the store by setting the storeKey prop to false.
In the example below, both lists NewerBooks and OlderBooks use the same resource ('books'), but their list parameters are stored separately (under the store keys 'newerBooks' and 'olderBooks' respectively). This allows to use both components in the same app, each having its own state (filters, sorting and pagination).
import {
Admin,
CustomRoutes,
Resource,
List,
DataTable,
} from "@/components/admin";
import { Route } from "react-router";
const NewerBooks = () => (
<List
resource="books"
storeKey="newerBooks"
sort={{ field: "year", order: "DESC" }}
>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="title" />
<DataTable.Col source="author" />
<DataTable.Col source="year" />
</DataTable>
</List>
);
const OlderBooks = () => (
<List
resource="books"
storeKey="olderBooks"
sort={{ field: "year", order: "ASC" }}
>
<DataTable>
<DataTable source="id" />
<DataTable source="title" />
<DataTable source="author" />
<DataTable source="year" />
</DataTable>
</List>
);
const Admin = () => {
return (
<Admin dataProvider={dataProvider}>
<CustomRoutes>
<Route path="/newerBooks" element={<NewerBooks />} />
<Route path="/olderBooks" element={<OlderBooks />} />
</CustomRoutes>
<Resource name="books" />
</Admin>
);
};