Migrate Resource List from Polaris React
Polaris web components don't have a component that owns all of the behavior in Polaris React ResourceList. Replace it with the resource list pattern, and keep query, selection, pagination, and action state in the app.
Use the IndexTable migration instead when the collection is tabular or merchants need to compare values across columns.
Anchor to Migrate a selectable resource listMigrate a selectable resource list
The following migration uses s-table to give each resource a consistent wide layout and an automatic narrow list layout. It keeps the checkbox, avatar, resource link, and item menu as separate controls instead of wrapping interactive children in one clickable row.
Migrating a customer resource list
Polaris web components
import {useEffect, useMemo, useState} from 'react';
import {useRevalidator, useSearchParams} from 'react-router';
function CustomersResourceList({customers, pageInfo, archiveCustomers}) {
const [searchParams, setSearchParams] = useSearchParams();
const revalidator = useRevalidator();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [archivedIds, setArchivedIds] = useState<Set<string>>(new Set());
const [archivePending, setArchivePending] = useState(false);
const [archivePendingId, setArchivePendingId] = useState<string | null>(null);
const query = searchParams.get('query') ?? '';
const status = searchParams.get('status') ?? 'all';
const visibleCustomers = useMemo(
() => customers.filter((customer) => !archivedIds.has(customer.id)),
[archivedIds, customers],
);
const pageIds = useMemo(
() =>
visibleCustomers
.filter((customer) => !customer.disabled)
.map((customer) => customer.id),
[visibleCustomers],
);
const allOnPageSelected =
pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
const someOnPageSelected =
!allOnPageSelected && pageIds.some((id) => selectedIds.has(id));
const selectionScope = ['query', 'status', 'cursor', 'direction']
.map((name) => `${name}=${searchParams.get(name) ?? ''}`)
.join('&');
useEffect(() => {
setSelectedIds(new Set());
}, [selectionScope]);
useEffect(() => {
setSelectedIds(
(current) =>
new Set([...current].filter((id) => pageIds.includes(id))),
);
}, [pageIds]);
function updateView(name: string, value: string | null) {
const next = new URLSearchParams(searchParams);
next.delete('cursor');
next.delete('direction');
if (value && value !== 'all') {
next.set(name, value);
} else {
next.delete(name);
}
setSearchParams(next, {replace: true});
setSelectedIds(new Set());
}
function togglePage(checked: boolean) {
setSelectedIds((current) => {
const next = new Set(current);
pageIds.forEach((id) => (checked ? next.add(id) : next.delete(id)));
return next;
});
}
function toggleCustomer(id: string, checked: boolean) {
setSelectedIds((current) => {
const next = new Set(current);
checked ? next.add(id) : next.delete(id);
return next;
});
}
function changePage(cursor: string, direction: 'previous' | 'next') {
const next = new URLSearchParams(searchParams);
next.set('cursor', cursor);
next.set('direction', direction);
setSearchParams(next);
setSelectedIds(new Set());
}
async function archiveSelection() {
const ids = pageIds.filter((id) => selectedIds.has(id));
if (ids.length === 0) return;
setArchivePending(true);
try {
await archiveCustomers(ids);
setArchivedIds((current) => new Set([...current, ...ids]));
setSelectedIds(new Set());
revalidator.revalidate();
shopify.toast.show(`${ids.length} customers archived`);
} catch {
shopify.toast.show("Customers couldn't be archived", {isError: true});
} finally {
setArchivePending(false);
}
}
async function archiveCustomer(id: string, name: string) {
setArchivePendingId(id);
try {
await archiveCustomers([id]);
setArchivedIds((current) => new Set(current).add(id));
setSelectedIds((current) => {
const next = new Set(current);
next.delete(id);
return next;
});
revalidator.revalidate();
shopify.toast.show(`${name} archived`);
} catch {
shopify.toast.show(`${name} couldn't be archived`, {isError: true});
} finally {
setArchivePendingId(null);
}
}
return (
<s-section padding="none" accessibilityLabel="Customers">
<s-stack gap="none">
<s-table
paginate
loading={pageInfo.loading}
hasPreviousPage={pageInfo.hasPreviousPage}
hasNextPage={pageInfo.hasNextPage}
onPreviousPage={() =>
changePage(pageInfo.startCursor, 'previous')
}
onNextPage={() => changePage(pageInfo.endCursor, 'next')}
>
{selectedIds.size > 0 ? (
<s-query-container slot="filters">
<s-box padding="small" background="strong">
<s-stack
direction="inline"
gap="small"
justifyContent="space-between"
>
<s-checkbox
checked={allOnPageSelected}
indeterminate={someOnPageSelected}
label={`${selectedIds.size} selected on this page`}
onChange={(event) =>
togglePage(event.currentTarget.checked)
}
/>
<s-button
loading={archivePending}
onClick={archiveSelection}
>
Archive customers
</s-button>
</s-stack>
</s-box>
</s-query-container>
) : (
<s-query-container slot="filters">
<s-stack gap="small">
<s-grid
gap="small"
alignItems="end"
gridTemplateColumns="repeat(auto-fit, minmax(12rem, 1fr))"
>
<s-search-field
label="Search"
value={query}
onInput={(event) =>
updateView('query', event.currentTarget.value || null)
}
/>
<s-select
label="Status"
value={status}
onChange={(event) =>
updateView('status', event.currentTarget.value)
}
>
<s-option value="all">Any status</s-option>
<s-option value="active">Active</s-option>
<s-option value="disabled">Disabled</s-option>
</s-select>
</s-grid>
</s-stack>
</s-query-container>
)}
<s-table-header-row>
<s-table-header listSlot="primary">
<s-stack direction="inline" gap="small" alignItems="center">
<s-checkbox
checked={allOnPageSelected}
indeterminate={someOnPageSelected}
disabled={pageIds.length === 0}
accessibilityLabel={`Select all ${pageIds.length} customers on this page`}
onChange={(event) =>
togglePage(event.currentTarget.checked)
}
/>
<s-text>Customer</s-text>
</s-stack>
</s-table-header>
<s-table-header listSlot="labeled">Location</s-table-header>
<s-table-header listSlot="secondary">Actions</s-table-header>
</s-table-header-row>
<s-table-body>
{visibleCustomers.map((customer) => {
const menuId = `customer-actions-${customer.id}`;
return (
<s-table-row key={customer.id}>
<s-table-cell>
<s-stack direction="inline" gap="small" alignItems="center">
<s-checkbox
checked={selectedIds.has(customer.id)}
disabled={customer.disabled}
accessibilityLabel={`Select ${customer.name}`}
onChange={(event) =>
toggleCustomer(
customer.id,
event.currentTarget.checked,
)
}
/>
<s-avatar
alt={customer.name}
initials={customer.initials}
/>
{customer.disabled ? (
<s-text type="strong">{customer.name}</s-text>
) : (
<s-link href={`/app/customers/${customer.id}`}>
{customer.name}
</s-link>
)}
</s-stack>
</s-table-cell>
<s-table-cell>{customer.location}</s-table-cell>
<s-table-cell>
<s-button
icon="menu-horizontal"
variant="tertiary"
accessibilityLabel={`Actions for ${customer.name}`}
commandFor={menuId}
/>
<s-menu
id={menuId}
accessibilityLabel={`Actions for ${customer.name}`}
>
{!customer.disabled && (
<s-button href={`/app/customers/${customer.id}/edit`}>
Edit customer
</s-button>
)}
<s-button
tone="critical"
disabled={customer.disabled}
loading={archivePendingId === customer.id}
onClick={() =>
archiveCustomer(customer.id, customer.name)
}
>
Archive customer
</s-button>
</s-menu>
</s-table-cell>
</s-table-row>
);
})}
</s-table-body>
</s-table>
{!pageInfo.loading && visibleCustomers.length === 0 && (
<s-box padding="large">
<s-stack gap="small" alignItems="center">
<s-heading>No customers found</s-heading>
<s-paragraph>Clear the filters or try another search.</s-paragraph>
<s-button
onClick={() => {
setSearchParams(new URLSearchParams(), {replace: true});
setSelectedIds(new Set());
}}
>
Clear filters
</s-button>
</s-stack>
</s-box>
)}
</s-stack>
</s-section>
);
}Polaris React
import {
Avatar,
ChoiceList,
Filters,
ResourceItem,
ResourceList,
Text,
} from '@shopify/polaris';
export function CustomersResourceList({customers, pageInfo, archiveCustomers}) {
const [query, setQuery] = useState('');
const [status, setStatus] = useState([]);
const [selectedItems, setSelectedItems] = useState([]);
return (
<ResourceList
resourceName={{singular: 'customer', plural: 'customers'}}
items={customers}
renderItem={(customer) => (
<ResourceItem
id={customer.id}
url={`/app/customers/${customer.id}`}
media={<Avatar name={customer.name} initials={customer.initials} />}
accessibilityLabel={`View ${customer.name}`}
disabled={customer.disabled}
shortcutActions={[
{content: 'Edit customer', url: `/app/customers/${customer.id}/edit`},
{
content: 'Archive customer',
destructive: true,
onAction: () => archiveCustomers([customer.id]),
},
]}
>
<Text as="h3" fontWeight="semibold">{customer.name}</Text>
<div>{customer.location}</div>
</ResourceItem>
)}
filterControl={
<Filters
queryValue={query}
onQueryChange={setQuery}
onQueryClear={() => setQuery('')}
filters={[
{
key: 'status',
label: 'Status',
filter: (
<ChoiceList
title="Status"
choices={[
{label: 'Active', value: 'active'},
{label: 'Disabled', value: 'disabled'},
]}
selected={status}
onChange={setStatus}
/>
),
},
]}
/>
}
selectedItems={selectedItems}
onSelectionChange={setSelectedItems}
bulkActions={[
{
content: 'Archive customers',
onAction: () => archiveCustomers(selectedItems),
},
]}
loading={pageInfo.loading}
pagination={pageInfo.pagination}
/>
);
}Preview
Anchor to Replace ResourceList propertiesReplace Resource List properties
| Polaris React | Polaris web components | Migration notes |
|---|---|---|
items and renderItem | Map records to s-table-row and s-table-cell elements | Use a stable resource ID as the React key, and assign listSlot values on headers for the narrow layout. |
idForItem | App-owned ID extraction | Normalize IDs before deriving selection or action payloads. |
resourceName | Labels in checkboxes, counts, headings, and empty states | Include the singular or plural resource name where the old component generated it. |
filterControl | s-search-field, filter controls, and removable s-clickable-chip elements | Back every control with the same state used to load results. |
selectedItems and onSelectionChange | A Set of stable IDs and s-checkbox handlers | Drive row, select-all, count, and action state from the same set. Keep page-level select-all in the primary table header; that header isn't available in the narrow list layout, so apps that need select-all there should surface the same control in the bulk-action bar. |
selectable | Render selection controls only when the operation is available | Don't render disabled or decorative checkboxes. |
bulkActions and promotedBulkActions | Visible s-button controls or a trigger and s-menu | Pass selected IDs to the backend and preserve selection after failure. |
sortValue, sortOptions, and onSortChange | An explicit s-select backed by route or URL state | Reset pagination when the result order changes. |
pagination | paginate, hasPreviousPage, hasNextPage, onPreviousPage, and onNextPage on s-table | Preserve the current query while changing the cursor. |
loading | loading on s-table | Associate loading state with the request that owns the displayed results. |
emptyState | A visible heading, explanation, and primary recovery action | Use it when the collection has no resources yet. |
emptySearchState | A no-results message and clear-filters action | Use it when resources exist but the current query has no matches. |
hasMoreItems and totalItemsCount | Explicit result-count text and backend page information | Don't infer a total from the current page length. |
Anchor to Keep one source of truthKeep one source of truth
When search, filters, or sort changes:
- Update the URL or route state used by the backend query.
- Remove cursors from the previous query.
- Clear or reconcile selected IDs when the active query or page changes, including through browser Back or Forward navigation.
- Load the new results and ignore stale responses.
- Render loading, no-results, error, or populated state for that same request.
For select-all, define whether it selects the current page or every result matching the query. The example selects only the current page. A cross-page selection needs a separate mode, an explicit matching-result count, and a backend operation that receives the active query, not only the loaded IDs.
Anchor to Compose resource rows safelyCompose resource rows safely
Keep resource navigation and actions as real controls:
- Give the identifying column
listSlot="primary", label supporting details withlabeled, and place a compact row-action trigger insecondaryso the same markup becomes a useful narrow list. - Use
s-linkfor the resource name so modified clicks and link semantics continue to work. - Put the selection checkbox in its own grid cell and include the resource name in its accessibility label.
- Open contextual actions with a button whose
commandForpoints to a siblings-menu. - Render disabled resources without a navigation link or enabled actions if they can't be opened or changed.
- Keep essential secondary information visible rather than moving it into an action menu.
Don't put a checkbox, link, or action button inside an s-clickable row. Nested interactive controls create ambiguous pointer and keyboard behavior.
Anchor to Handle action resultsHandle action results
Set pending state before calling a bulk operation and prevent repeat submissions. On success, clear selection, refresh results, and show confirmation. On failure, keep selection so the merchant can retry and show an error toast or banner.
Apply the same result handling to item actions. The example marks only the active row action as loading. On success, it removes the archived customer from local visible state, clears any selection for that ID, refreshes backend results, and shows a confirmation toast. On failure, it keeps the row in place and shows an error toast so the merchant can retry.
If an action is destructive or difficult to reverse, use the Modal API to confirm it before changing data.
Anchor to Test the migrationTest the migration
- Search, apply each filter, clear filters, and verify the backend receives the displayed values.
- Select one item, a partial page, and the full page, including disabled resources.
- Run item and bulk actions through pending, success, and failure states.
- Navigate forward and backward and confirm that filters persist while selection follows the chosen rule.
- Test initial empty, no-results, loading, stale-response, and error states.
- Confirm that header-row select-all isn't available in the narrow list layout; if the app requires narrow-layout select-all, verify that the bulk-action bar provides it.
- Verify links, menus, checkbox labels, focus order, and the narrow app viewport layout with a keyboard and screen reader.
Anchor to Remove Polaris ReactRemove Polaris React
After the complete resource-list feature is migrated, remove ResourceList, ResourceItem, useResourceSelection, filter descriptor builders, and Polaris-only action adapters that no longer have callers. Remove @shopify/polaris only after no other route in scope imports it.
- Resource list pattern
- Migrate ResourceItem from Polaris React
- Migrate Filters from Polaris React
- Migrate SelectAllActions from Polaris React