---
title: Migrate UnstableBulkActions from Polaris React
description: >-
  Learn how to migrate Polaris React UnstableBulkActions to Polaris web
  components in an embedded app.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/unstable-bulk-actions
  md: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/unstable-bulk-actions.md
api_name: app-home
---

# 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`](https://shopify.dev/docs/api/app-home/web-components/layout-and-structure/table) or [resource list](https://shopify.dev/docs/api/app-home/patterns/compositions/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.

***

## Replace 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

```tsx
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

```tsx
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},
      ]}
    />
  );
}
```

***

## Map 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:

* `allOnPageSelected` is true when the page isn't empty and every page ID is in `selectedIds`.
* `someOnPageSelected` is true when at least one page ID is selected but `allOnPageSelected` is 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.

***

## Map 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.

***

## Migrate selection across pages

The old `paginatedSelectAllText` and `paginatedSelectAllAction` offered a second step after selecting the current page. Preserve the distinction explicitly:

1. Selecting the header checkbox selects or clears the resources on the current page.

2. 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**.

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

4. 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**.

***

## Removed 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. |

***

## Test 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.

***

## Remove 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.

***

## Related guidance

* [Index table pattern](https://shopify.dev/docs/api/app-home/patterns/compositions/index-table)
* [Resource list pattern](https://shopify.dev/docs/api/app-home/patterns/compositions/resource-list)
* [Table component](https://shopify.dev/docs/api/app-home/web-components/layout-and-structure/table)
* [Menu component](https://shopify.dev/docs/api/app-home/web-components/actions/menu)
* [Migrate IndexTable from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-table)
* [Migrate from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react)

***
