Skip to main content

Migrate ResourceList 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

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>
);
}
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 ResourceList properties

Polaris ReactPolaris web componentsMigration notes
items and renderItemMap records to s-table-row and s-table-cell elementsUse a stable resource ID as the React key, and assign listSlot values on headers for the narrow layout.
idForItemApp-owned ID extractionNormalize IDs before deriving selection or action payloads.
resourceNameLabels in checkboxes, counts, headings, and empty statesInclude the singular or plural resource name where the old component generated it.
filterControls-search-field, filter controls, and removable s-clickable-chip elementsBack every control with the same state used to load results.
selectedItems and onSelectionChangeA Set of stable IDs and s-checkbox handlersDrive 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.
selectableRender selection controls only when the operation is availableDon't render disabled or decorative checkboxes.
bulkActions and promotedBulkActionsVisible s-button controls or a trigger and s-menuPass selected IDs to the backend and preserve selection after failure.
sortValue, sortOptions, and onSortChangeAn explicit s-select backed by route or URL stateReset pagination when the result order changes.
paginationpaginate, hasPreviousPage, hasNextPage, onPreviousPage, and onNextPage on s-tablePreserve the current query while changing the cursor.
loadingloading on s-tableAssociate loading state with the request that owns the displayed results.
emptyStateA visible heading, explanation, and primary recovery actionUse it when the collection has no resources yet.
emptySearchStateA no-results message and clear-filters actionUse it when resources exist but the current query has no matches.
hasMoreItems and totalItemsCountExplicit result-count text and backend page informationDon'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:

  1. Update the URL or route state used by the backend query.
  2. Remove cursors from the previous query.
  3. Clear or reconcile selected IDs when the active query or page changes, including through browser Back or Forward navigation.
  4. Load the new results and ignore stale responses.
  5. 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 with labeled, and place a compact row-action trigger in secondary so the same markup becomes a useful narrow list.
  • Use s-link for 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 commandFor points to a sibling s-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.


  • 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.



Was this page helpful?