---
title: Migrate ResourceList from Polaris React
description: >-
  Replace Polaris React ResourceList with an explicit resource-list composition
  that preserves filtering, selection, actions, loading, empty states, and
  pagination.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/resource-list
  md: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/resource-list.md
api_name: app-home
---

# 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](https://shopify.dev/docs/api/app-home/patterns/compositions/resource-list), and keep query, selection, pagination, and action state in the app.

Use the [IndexTable migration](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-table) instead when the collection is tabular or merchants need to compare values across columns.

***

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

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

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

***

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

***

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

***

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

***

## Handle 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](https://shopify.dev/docs/api/app-home/apis/user-interface-and-interactions/modal-api) to confirm it before changing data.

***

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

***

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

***

## Related guidance

* [Resource list pattern](https://shopify.dev/docs/api/app-home/patterns/compositions/resource-list)
* [Migrate ResourceItem from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/resource-item)
* [Migrate Filters from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/filters)
* [Migrate SelectAllActions from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/select-all-actions)

***
