2. Building the List
Write the list page, compose fields, and handle relationships.
Writing a Page Component
The <ListGuesser> component isn't meant for production use—it's just there to help you quickly set up an admin interface. Eventually, you'll need to replace the ListGuesser in the users resource with a custom React component. Fortunately, ListGuesser provides the guessed list code right in the console:
Copy this code and create a new UserList component in a new file called users.tsx:
// app/admin/users.tsx
import { DataTable, List } from "@/components/admin";
export const UserList = () => (
<List>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
<DataTable.Col source="username" />
<DataTable.Col source="email" />
<DataTable.Col source="address.street" />
<DataTable.Col source="phone" />
<DataTable.Col source="website" />
<DataTable.Col source="company.name" />
</DataTable>
</List>
);Next, update App.tsx to use this new component instead of ListGuesser:
// app/admin/App.tsx
import { Resource } from "ra-core";
import { Admin, ListGuesser } from "@/components/admin";
import { dataProvider } from "./data-provider";
+import { UserList } from "./users";
const App = () => (
<Admin dataProvider={dataProvider}>
- <Resource name="users" list={ListGuesser} />
+ <Resource name="users" list={UserList} />
</Admin>
);
export default App;// src/users.tsx
import { DataTable, List } from "@/components/admin";
export const UserList = () => (
<List>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
<DataTable.Col source="username" />
<DataTable.Col source="email" />
<DataTable.Col source="address.street" />
<DataTable.Col source="phone" />
<DataTable.Col source="website" />
<DataTable.Col source="company.name" />
</DataTable>
</List>
);Next, update App.tsx to use this new component instead of ListGuesser:
// src/App.tsx
import { Resource } from "ra-core";
import { Admin, ListGuesser } from "@/components/admin";
import { dataProvider } from "./data-provider";
+import { UserList } from "./users";
const App = () => (
<Admin dataProvider={dataProvider}>
- <Resource name="users" list={ListGuesser} />
+ <Resource name="users" list={UserList} />
</Admin>
);
export default App;// app/admin/users.tsx
import { DataTable, List } from "~/components/admin";
export const UserList = () => (
<List>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
<DataTable.Col source="username" />
<DataTable.Col source="email" />
<DataTable.Col source="address.street" />
<DataTable.Col source="phone" />
<DataTable.Col source="website" />
<DataTable.Col source="company.name" />
</DataTable>
</List>
);Next, update App.tsx to use this new component instead of ListGuesser:
// app/admin/App.tsx
import { Resource } from "ra-core";
import { Admin, ListGuesser } from "~/components/admin";
import { dataProvider } from "./data-provider";
+import { UserList } from "./users";
const App = () => (
<Admin basename="/admin" dataProvider={dataProvider}>
- <Resource name="users" list={ListGuesser} />
+ <Resource name="users" list={UserList} />
</Admin>
);
export default App;import { DataTable, List } from "@/components/admin";
export const UserList = () => (
<List>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
<DataTable.Col source="username" />
<DataTable.Col source="email" />
<DataTable.Col source="address.street" />
<DataTable.Col source="phone" />
<DataTable.Col source="website" />
<DataTable.Col source="company.name" />
</DataTable>
</List>
);Next, update index.tsx to use this new component instead of ListGuesser:
import { Resource } from "ra-core";
import { tanStackRouterProvider } from "ra-router-tanstack";
import { createFileRoute } from "@tanstack/react-router";
-import { Admin, ListGuesser } from "@/components/admin";
+import { Admin } from "@/components/admin";
import { dataProvider } from "@/data-provider.ts";
+import { UserList } from "@/components/app/users.tsx";
export const Route = createFileRoute("/")({ component: App });
export function App() {
return (
<Admin routerProvider={tanStackRouterProvider} dataProvider={dataProvider}>
- <Resource name={"users"} list={ListGuesser} />
+ <Resource name={"users"} list={UserList} />
</Admin>
);
}Visually, nothing changes in the browser, but now the app uses a component that you can fully customize.
Composing Components
Let's take a closer look at the <UserList> component:
// users.tsx
import { DataTable, List } from "@/components/admin";
export const UserList = () => (
<List>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
<DataTable.Col source="username" />
<DataTable.Col source="email" />
<DataTable.Col source="address.street" />
<DataTable.Col source="phone" />
<DataTable.Col source="website" />
<DataTable.Col source="company.name" />
</DataTable>
</List>
);The root component, <List>, reads the query parameters, fetches data from the API, and places the data in a React context. It also provides callbacks for filtering, pagination, and sorting, allowing child components to access and modify the list parameters. <List> performs many tasks, but its syntax remains straightforward:
<List>
{/* children */}
</List>This demonstrates the goal of shadmin: helping developers build sophisticated applications with simple syntax.
In most frameworks, "simple" often implies limited capabilities, making it challenging to extend beyond basic features. shadmin addresses this through composition. <List> handles data fetching, while rendering is delegated to its child—in this case, <DataTable>. Essentially, the code composes the functionalities of <List> and <DataTable> functionalities.
Selecting Columns
<ListGuesser> created one column for every field in the API response.
That's a bit too much for a usable grid, so let's remove a couple of <DataTable.Col> components from the DataTable and see the effect:
// users.tsx
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
- <DataTable.Col source="username" />
<DataTable.Col source="email" />
- <DataTable.Col source="address.street" />
<DataTable.Col source="phone" />
<DataTable.Col source="website" />
<DataTable.Col source="company.name" />
</DataTable>In shadmin, most configuration is done through components. Instead of using a columns prop for configuration, shadmin leverages the children prop for flexibility, enabling you to add custom logic or change column types as needed.
Writing A Custom Field
The columns are rendered by the <DataTable.Col> component. By default, it renders the value as a simple string. You can customize how each column is rendered by passing a custom React component as the field prop.
Field components can read the record fetched from the API (e.g. { "id": 2, "name": "Ervin Howell", "website": "anastasia.net", ... }) using a custom context, and use the source prop (e.g. website) to get the value they should display (e.g. "anastasia.net").
Let's see that in practice by writing a custom field. For instance, this <UrlField> renders an URL as a clickable link:
// components/url-field.tsx
import { useRecordContext } from "ra-core";
export function UrlField({ source }: { source: string }) {
const record = useRecordContext();
if (!record) return null;
return (
<a className="text-indigo-600" href={`https://${record[source]}`}>
{record[source]}
</a>
);
}For each row, <DataTable> creates a RecordContext and stores the current record in it. useRecordContext allows you to read that record. It's one of the 50+ headless hooks that shadmin exposes to let you build your own components without forcing a particular UI.
You can then use the <UrlField> component in <UserList>.
// users.tsx
import { DataTable, List } from "@/components/admin";
+import { UrlField } from "@/components/url-field";
export const UserList = () => (
<List>
<DataTable>
<DataTable.Col source="id" />
<DataTable.Col source="name" />
<DataTable.Col source="email" />
<DataTable.Col source="phone" />
- <DataTable.Col source="website" />
+ <DataTable.Col source="website" field={UrlField} />
<DataTable.Col source="company.name" />
</DataTable>
</List>
);Handling Relationships
In JSONPlaceholder, each post record includes a userId field, which points to a user:
{
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto",
"userId": 1
}shadmin knows how to take advantage of these foreign keys to fetch references. Let's see how the ListGuesser manages them by creating a new <Resource> for the /posts API endpoint:
// App.tsx
import { Resource } from "ra-core";
import { Admin, ListGuesser } from "@/components/admin";
import { dataProvider } from "./data-provider";
import { UserList } from "./users";
const App = () => (
<Admin dataProvider={dataProvider}>
+ <Resource name="posts" list={ListGuesser} />
<Resource name="users" list={UserList} />
</Admin>
);The ListGuesser suggests using a <ReferenceField> for the userId field.
Let's play with this new field by creating the PostList component based on the code dumped by the guesser:
// posts.tsx
import {
DataTable,
List,
ReferenceField,
} from "@/components/admin";
export const PostList = () => (
<List>
<DataTable>
<DataTable.Col source="userId">
<ReferenceField source="userId" reference="users" />
</DataTable.Col>
<DataTable.Col source="id" />
<DataTable.Col source="title" />
<DataTable.Col source="body" />
</DataTable>
</List>
);// App.tsx
import { Resource } from "ra-core";
-import { Admin, ListGuesser } from "@/components/admin";
+import { Admin } from "@/components/admin";
import { dataProvider } from "./data-provider";
+import { PostList } from "./posts";
import { UserList } from "./users";
function App() {
return (
<Admin dataProvider={dataProvider}>
- <Resource name="posts" list={ListGuesser} />
+ <Resource name="posts" list={PostList} />
<Resource name="users" list={UserList} />
</Admin>
);
}
export default App;When displaying the posts list, shadmin is smart enough to display the name of the post author:
Tip: To customize how to represent a record, set the recordRepresentation prop of the <Resource>.
The <ReferenceField> component fetches the reference data, creates a RecordContext with the result, and renders the record representation (or its children).
Tip: Look at the network tab of your browser again: shadmin deduplicates requests for users and aggregates them in order to make only one HTTP request to the /users endpoint for the whole DataTable. That's one of many optimizations that keep the UI fast and responsive.
To finish the post list, place the post id field as the first column, and remove the body field.
From a UX point of view, fields containing large chunks of text should not appear in a DataTable, only in detail views.
Also, to make the Edit action stand out, let's replace the default rowClick action with an explicit action button.
// posts.tsx
import {
DataTable,
List,
ReferenceField,
+ EditButton,
} from "@/components/admin";
export const PostList = () => (
<List>
+ <DataTable rowClick={false}>
+ <DataTable.Col source="id" />
<DataTable.Col source="userId">
<ReferenceField source="userId" reference="users" />
</DataTable.Col>
- <DataTable.Col source="id" />
<DataTable.Col source="title" />
- <DataTable.Col source="body" />
+ <DataTable.Col>
+ <EditButton />
+ </DataTable.Col>
</DataTable>
</List>
);The Edit Button will lead to the post edit page, which we will create later.