Migrate Resource Item from Polaris React
Polaris web components don't have a like-for-like ResourceItem. Compose each item inside the resource list pattern, and keep its navigation, selection, content, and actions as separate controls.
Migrate ResourceItem with its containing ResourceList. Selection and bulk actions depend on the complete set of visible resource IDs, not an isolated row.
Anchor to Migrate a resource itemMigrate a resource item
The Polaris web components example includes the complete list so that the row's checkbox, select-all state, item actions, filters, and pagination use one state model.
Migrating customer resource items
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, ResourceItem, Text} from '@shopify/polaris';
export function CustomerItem({customer, archiveCustomer}) {
return (
<ResourceItem
id={customer.id}
url={`/app/customers/${customer.id}`}
external={false}
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: () => archiveCustomer(customer.id),
},
]}
>
<Text as="h3" fontWeight="semibold">{customer.name}</Text>
<div>{customer.location}</div>
</ResourceItem>
);
}Preview
Anchor to Replace ResourceItem propertiesReplace Resource Item properties
| Polaris React | Polaris web components | Migration notes |
|---|---|---|
id | Stable app-owned ID and React key | Use the same ID for selection and backend action payloads. |
url | href on the primary s-link | Keep resource navigation as a real link. |
external | target="_blank" on the link when navigation truly leaves the embedded app | Don't open internal app routes in a new tab. |
onClick | onClick on an explicit s-button | Use a button only when the row performs an action instead of navigation. |
media | s-avatar, s-thumbnail, or s-image in its own grid cell | Preserve fallback text and alternative text. |
children | Explicit s-heading, s-text, s-paragraph, badge, and layout children | Keep the name and essential state scannable. |
accessibilityLabel and name | Visible link text and control-specific accessibilityLabel values | Name selection and action controls separately. |
shortcutActions | An action trigger and sibling s-menu, or visible buttons | Use visible actions when they are frequent or primary. |
persistActions | Keep essential actions visible | Don't force persistent actions into an overflow menu. |
disabled | Disable selection and actions, and remove navigation when the item can't be opened | Preserve the reason in visible supporting content when merchants need it. |
Selection inherited from ResourceList | A row s-checkbox backed by the list's selected-ID set | Don't give the row a second selection state. |
If the old item uses onClick and url together, decide which behavior is primary. Don't make a link also run an unrelated mutation. Put the mutation on a separate button or menu item.
Anchor to Preserve item actionsPreserve item actions
Convert shortcutActions descriptors to explicit controls. Map content to button text, url to href, onAction to onClick, disabled to disabled, and destructive to tone="critical".
When the action is asynchronous, set pending state on the initiating control, prevent duplicate submissions, show success or failure feedback, and update the resource or list data. Confirm destructive actions that are difficult to reverse.
Anchor to Test the migrationTest the migration
- Open internal and external resource links with pointer, keyboard, and modified clicks.
- Select and deselect enabled items and verify disabled items can't enter the selected-ID set.
- Exercise every item action through success, failure, and pending states.
- Test missing images, long names, long secondary content, and narrow containers.
- Verify that link, checkbox, and menu controls have distinct names and focus stops.
Anchor to Remove Polaris ReactRemove Polaris React
After all items and their containing list are migrated, remove ResourceItem, related Polaris action descriptors, and wrappers used only for row layout. Remove @shopify/polaris only after no other route in scope imports it.