Migrate Unstable Bulk Actions from Polaris React
Polaris web components don't provide a standalone bulk-actions toolbar. Replace Polaris React UnstableBulkActions from @shopify/polaris with selection checkboxes and action controls inside the same s-table or resource list that owns the resources.
The app owns selected resource IDs, page-selection calculations, cross-page scope, mutations, results, and clearing selection. Keep that state with filtering, sorting, and pagination so an action always applies to the resources the merchant intended.
Anchor to Replace the toolbar and selection togetherReplace the toolbar and selection together
The example composes select-all and per-row checkboxes, a visible selection count, a promoted action, and a menu of secondary actions. The toolbar replaces the table filters only while resources are selected.
Migrating UnstableBulkActions with table selection
Polaris web components
import {useState} from 'react';
const products = [
{id: '1', title: 'Snowboard'},
{id: '2', title: 'Bindings'},
];
interface ProductsBulkActionsProps {
archiveProducts(ids: string[]): Promise<void>;
}
export function ProductsBulkActions({
archiveProducts,
}: ProductsBulkActionsProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
new Set(['1']),
);
const [bulkPending, setBulkPending] = useState(false);
const pageIds = products.map(({id}) => id);
const allSelected = pageIds.every((id) => selectedIds.has(id));
const someSelected =
!allSelected && pageIds.some((id) => selectedIds.has(id));
function togglePage(checked: boolean) {
setSelectedIds(checked ? new Set(pageIds) : new Set());
}
function toggleRow(id: string, checked: boolean) {
setSelectedIds((current) => {
const next = new Set(current);
checked ? next.add(id) : next.delete(id);
return next;
});
}
async function archiveSelection() {
setBulkPending(true);
try {
await archiveProducts([...selectedIds]);
shopify.toast.show(`${selectedIds.size} products archived`);
setSelectedIds(new Set());
} catch {
shopify.toast.show('Products couldn’t be archived', {isError: true});
} finally {
setBulkPending(false);
}
}
return (
<s-section padding="none" accessibilityLabel="Products">
<s-table>
{selectedIds.size > 0 && (
<s-box slot="filters" padding="small" background="strong">
<s-stack
direction="inline"
gap="base"
alignItems="center"
justifyContent="space-between"
>
<s-text type="strong">{selectedIds.size} selected</s-text>
<s-stack direction="inline" gap="small">
<s-button
variant="secondary"
loading={bulkPending}
onClick={archiveSelection}
>
Archive
</s-button>
<s-button commandFor="more-bulk-actions">
More actions
</s-button>
<s-menu
id="more-bulk-actions"
accessibilityLabel="More bulk actions"
>
<s-button variant="tertiary">Add tags</s-button>
<s-button variant="tertiary" tone="critical">
Delete
</s-button>
</s-menu>
</s-stack>
</s-stack>
</s-box>
)}
<s-table-header-row>
<s-table-header listSlot="primary">
<s-stack direction="inline" gap="small" alignItems="center">
<s-checkbox
checked={allSelected}
indeterminate={someSelected}
accessibilityLabel="Select all products on this page"
onChange={(event) =>
togglePage(event.currentTarget.checked)
}
/>
<s-text>Product</s-text>
</s-stack>
</s-table-header>
</s-table-header-row>
<s-table-body>
{products.map((product) => (
<s-table-row key={product.id}>
<s-table-cell>
<s-stack direction="inline" gap="small" alignItems="center">
<s-checkbox
checked={selectedIds.has(product.id)}
accessibilityLabel={`Select ${product.title}`}
onChange={(event) =>
toggleRow(product.id, event.currentTarget.checked)
}
/>
<s-text>{product.title}</s-text>
</s-stack>
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
</s-section>
);
}Polaris React
import {UnstableBulkActions} from '@shopify/polaris';
interface ProductBulkActionsProps {
archiveSelection(): void;
addTags(): void;
deleteSelection(): void;
toggleAll(): void;
}
export function ProductBulkActions({
archiveSelection,
addTags,
deleteSelection,
toggleAll,
}: ProductBulkActionsProps) {
return (
<UnstableBulkActions
selectMode
selected="indeterminate"
label="1 selected"
accessibilityLabel="Select all products on this page"
onToggleAll={toggleAll}
promotedActions={[
{content: 'Archive', onAction: archiveSelection},
]}
actions={[
{content: 'Add tags', onAction: addTags},
{content: 'Delete', onAction: deleteSelection},
]}
/>
);
}Preview
Anchor to Map selection propertiesMap selection properties
| Polaris React property | Polaris web components composition |
|---|---|
selected={true} | Set the header s-checkbox checked when every resource on the current page is selected. |
selected="indeterminate" | Set the header checkbox indeterminate when some, but not all, current-page resources are selected. |
onToggleAll | Handle the header checkbox change event and add or remove every current-page ID. |
label | Render a visible count such as 3 selected in the bulk-action bar. Derive it from app state. |
accessibilityLabel | Put a specific accessibilityLabel on the select-all checkbox and each row checkbox. |
selectMode | Conditionally render selection checkboxes and the bulk-action bar from app-owned mode or selected-ID state. |
Calculate header state from the IDs actually rendered on the page:
allOnPageSelectedis true when the page isn't empty and every page ID is inselectedIds.someOnPageSelectedis true when at least one page ID is selected butallOnPageSelectedis false.- A row checkbox is checked when its stable resource ID is in
selectedIds.
Don't use row indexes as IDs. Sorting, filtering, deletion, and pagination can change an index while an action is pending.
Anchor to Map action descriptorsMap action descriptors
Render the highest-value bulk action as a visible s-button. Put additional actions in s-menu, opened by a button with commandFor.
| Polaris React action field | Button or menu-item migration |
|---|---|
content | Move into the button's visible child text. |
onAction | Rename to onClick. |
url | Rename to href when bulk action navigation is intentional. |
disabled | Keep disabled on the individual button. |
loading | Keep loading on the action whose mutation is pending. |
accessibilityLabel | Keep only when the visible action label needs additional context. |
badge | No direct equivalent. Make new or important behavior clear in the visible label and surrounding content. |
Map promotedActions to visible buttons, but don't reproduce the old measurement logic that moved them into overflow. Choose a small, stable promoted set. Map actions and MenuGroupDescriptor entries to an s-menu; use s-section children inside the menu when groups need headings.
For destructive actions, confirm the operation before sending selected IDs. Keep selection if the mutation fails so the merchant can retry; clear it only after success or an explicit cancel.
Anchor to Migrate selection across pagesMigrate selection across pages
The old paginatedSelectAllText and paginatedSelectAllAction offered a second step after selecting the current page. Preserve the distinction explicitly:
-
Selecting the header checkbox selects or clears the resources on the current page.
-
When all current-page resources are selected and more results exist, show text such as All 50 products on this page are selected and a button such as Select all 2,430 matching products.
-
Store whether selection represents explicit IDs or all resources matching the current query. For query-wide selection, send the filter snapshot plus excluded IDs to the backend instead of loading thousands of IDs into the browser.
-
Clear query-wide selection when search, filters, sort scope, or the underlying resource set changes enough to make the selection ambiguous.
Show the total scope in destructive confirmations and action results. Delete 2,430 products is safer than Delete selected products.
Anchor to Removed propertiesRemoved properties
| Removed property | Migration |
|---|---|
buttonSize | Remove it. The destination buttons use their context-appropriate size. |
onMoreActionPopoverToggle | Remove toolbar state tied only to the old popover. s-menu owns opening, focus, and dismissal. |
innerRef or forwarded ref | Move focus to a meaningful checkbox or action only when the workflow requires it. |
onSelectModeToggle | Remove the deprecated callback and update app-owned selection mode directly. |
isSticky | Remove it. Don't create a custom sticky toolbar inside the embedded iframe. |
width | Remove the deprecated measurement input. Let the table and action composition respond to available space. |
Anchor to Test the migrationTest the migration
- Select none, one, some, and every resource on the current page; verify checked and indeterminate states.
- Change filters, search, sorting, and pages while selection exists, and verify the documented reset or retention behavior.
- Exercise every promoted and menu action in success, loading, error, and confirmation states.
- Test query-wide selection, exclusions, and totals against the backend query that performs the mutation.
- Verify checkbox labels, selection counts, menu keyboard behavior, focus restoration, and destructive confirmations.
Anchor to Remove Polaris ReactRemove Polaris React
After every bulk-action workflow is migrated, remove old action descriptors, measurement helpers, selection wrappers, and the Polaris React UnstableBulkActions import. Remove @shopify/polaris only after no other route in scope imports it.
- Index table pattern
- Resource list pattern
- Table component
- Menu component
- Migrate IndexTable from Polaris React
- Migrate from Polaris React