---
title: Migrate IndexTable from Polaris React
description: >-
  Replace the Polaris React IndexTable component with an s-table composition
  that owns selection, filters, sorting, pagination, and bulk actions together.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-table
  md: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-table.md
api_name: app-home
---

# Migrate Index​Table from Polaris React

Replace Polaris React `IndexTable` with the [index table pattern](https://shopify.dev/docs/api/app-home/patterns/compositions/index-table): `s-table` displays the rows, while separate controls manage selection, filtering, sorting, and bulk actions. Keep one source of truth for filters, sort, cursor, and selected IDs. During an incremental migration, old and new controls can share that application state.

***

## Migrate an index table

The Polaris web components example includes the table plus the state that Polaris React previously coordinated through `IndexTable`, `useIndexResourceState`, and adjacent filter controls.

## Migrating a selectable products table

##### Polaris web components

```tsx
import {useEffect, useMemo, useState} from 'react';
import {useRevalidator, useSearchParams} from 'react-router';

function ProductsIndex({products, pageInfo, archiveProducts}) {
  const [searchParams, setSearchParams] = useSearchParams();
  const revalidator = useRevalidator();
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  const [bulkPending, setBulkPending] = useState(false);

  const query = searchParams.get('query') ?? '';
  const status = searchParams.get('status') ?? 'all';
  const sort = searchParams.get('sort') ?? 'title-asc';
  const pageIds = useMemo(
    () => products.map((product) => product.id),
    [products],
  );
  const allOnPageSelected =
    pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id));
  const someOnPageSelected =
    !allOnPageSelected && pageIds.some((id) => selectedIds.has(id));
  const selectionScope = ['query', 'status', 'sort', '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(
    patch: Record<string, string | null>,
    {replace = true}: {replace?: boolean} = {},
  ) {
    const next = new URLSearchParams(searchParams);
    next.delete('cursor');
    next.delete('direction');

    for (const [name, value] of Object.entries(patch)) {
      if (value) {
        next.set(name, value);
      } else {
        next.delete(name);
      }
    }

    setSearchParams(next, {replace});
    setSelectedIds(new Set());
  }

  function togglePage(checked) {
    setSelectedIds((current) => {
      const next = new Set(current);
      for (const id of pageIds) {
        checked ? next.add(id) : next.delete(id);
      }
      return next;
    });
  }

  function toggleRow(id, checked) {
    setSelectedIds((current) => {
      const next = new Set(current);
      checked ? next.add(id) : next.delete(id);
      return next;
    });
  }

  async function archiveSelection() {
    const activeSelectedIds = pageIds.filter((id) => selectedIds.has(id));
    if (activeSelectedIds.length === 0) return;

    setBulkPending(true);
    try {
      await archiveProducts(activeSelectedIds);
      shopify.toast.show(`${activeSelectedIds.length} products archived`);
      setSelectedIds(new Set());
      revalidator.revalidate();
    } catch {
      shopify.toast.show('Products couldn’t be archived', {isError: true});
    } finally {
      setBulkPending(false);
    }
  }

  const showBulkActions = selectedIds.size > 0;

  return (
    <s-section padding="none" accessibilityLabel="Products">
      <s-table
        paginate
        loading={pageInfo.loading}
        hasPreviousPage={pageInfo.hasPreviousPage}
        hasNextPage={pageInfo.hasNextPage}
        onPreviousPage={() =>
          updateView(
            {cursor: pageInfo.startCursor, direction: 'previous'},
            {replace: false},
          )
        }
        onNextPage={() =>
          updateView(
            {cursor: pageInfo.endCursor, direction: 'next'},
            {replace: false},
          )
        }
      >
        {showBulkActions ? (
          <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
                  variant="secondary"
                  loading={bulkPending}
                  onClick={archiveSelection}
                >
                  Archive products
                </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})
                  }
                />
                <s-select
                  label="Status"
                  value={status}
                  onChange={(event) =>
                    updateView({
                      status:
                        event.currentTarget.value === 'all'
                          ? null
                          : event.currentTarget.value,
                    })
                  }
                >
                  <s-option value="all">Any status</s-option>
                  <s-option value="active">Active</s-option>
                  <s-option value="draft">Draft</s-option>
                </s-select>
                <s-select
                  label="Sort by"
                  value={sort}
                  onChange={(event) =>
                    updateView({sort: event.currentTarget.value})
                  }
                >
                  <s-option value="title-asc">Title, A–Z</s-option>
                  <s-option value="title-desc">Title, Z–A</s-option>
                  <s-option value="updated-desc">Recently updated</s-option>
                </s-select>
              </s-grid>

              {(query || status !== 'all') && (
                <s-stack direction="inline" gap="small">
                  {query && (
                    <s-clickable-chip
                      removable
                      accessibilityLabel="Remove search filter"
                      onRemove={() => updateView({query: null})}
                    >
                      Search: {query}
                    </s-clickable-chip>
                  )}
                  {status !== 'all' && (
                    <s-clickable-chip
                      removable
                      accessibilityLabel="Remove status filter"
                      onRemove={() => updateView({status: null})}
                    >
                      Status: {status}
                    </s-clickable-chip>
                  )}
                </s-stack>
              )}
            </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}
                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 listSlot="secondary">Status</s-table-header>
        </s-table-header-row>

        <s-table-body>
          {products.map((product) => {
            const checkboxId = `select-${product.id}`;
            return (
              <s-table-row key={product.id} clickDelegate={checkboxId}>
                <s-table-cell>
                  <s-stack direction="inline" gap="small" alignItems="center">
                    <s-checkbox
                      id={checkboxId}
                      checked={selectedIds.has(product.id)}
                      accessibilityLabel={`Select ${product.title}`}
                      onChange={(event) =>
                        toggleRow(product.id, event.currentTarget.checked)
                      }
                    />
                    <s-link href={`/app/products/${product.id}`}>
                      {product.title}
                    </s-link>
                  </s-stack>
                </s-table-cell>
                <s-table-cell>
                  <s-badge
                    tone={product.status === 'active' ? 'success' : 'neutral'}
                  >
                    {product.status}
                  </s-badge>
                </s-table-cell>
              </s-table-row>
            );
          })}
        </s-table-body>
      </s-table>
    </s-section>
  );
}
```

##### Polaris React

```tsx
import {
  Badge,
  IndexTable,
  Text,
  useIndexResourceState,
} from '@shopify/polaris';

export function ProductsIndex({products, pageInfo, archiveProducts}) {
  const {
    selectedResources,
    allResourcesSelected,
    handleSelectionChange,
  } = useIndexResourceState(products);

  return (
    <IndexTable
      resourceName={{singular: 'product', plural: 'products'}}
      itemCount={products.length}
      selectedItemsCount={
        allResourcesSelected ? 'All' : selectedResources.length
      }
      onSelectionChange={handleSelectionChange}
      headings={[{title: 'Product'}, {title: 'Status'}]}
      bulkActions={[
        {
          content: 'Archive products',
          onAction: () => archiveProducts(selectedResources),
        },
      ]}
      pagination={{
        hasPrevious: pageInfo.hasPreviousPage,
        hasNext: pageInfo.hasNextPage,
        onPrevious: pageInfo.loadPrevious,
        onNext: pageInfo.loadNext,
      }}
    >
      {products.map((product, index) => (
        <IndexTable.Row
          id={product.id}
          key={product.id}
          position={index}
          selected={selectedResources.includes(product.id)}
        >
          <IndexTable.Cell>
            <Text as="span" fontWeight="semibold">
              {product.title}
            </Text>
          </IndexTable.Cell>
          <IndexTable.Cell>
            <Badge tone={product.status === 'active' ? 'success' : undefined}>
              {product.status}
            </Badge>
          </IndexTable.Cell>
        </IndexTable.Row>
      ))}
    </IndexTable>
  );
}
```

This example assumes a React Router data route. Its loader reads `query`, `status`, `sort`, `cursor`, and `direction` from the request URL and returns `{products, pageInfo}`. URL state and server cursor pagination are choices made by this example, not requirements of `s-table`.

***

## Rebuild selection state

`s-table` doesn't own selected resource IDs. Keep selection in app state, keyed by stable resource IDs:

1. Derive `pageIds` from the currently displayed results.
2. Set `allOnPageSelected` when every page ID exists in `selectedIds`.
3. Set `someOnPageSelected` when at least one, but not every, page ID is selected.
4. Update the set from the select-all checkbox and each row checkbox.
5. Clear or reconcile selection when the active query or page changes, including through browser Back or Forward navigation.

Use `indeterminate` on the select-all `s-checkbox` for the partial state. Give every row checkbox an accessibility label that includes the resource name.

The example selects only the current page. If the existing app supports selecting every result across pages, then keep that as a distinct app-owned mode with an explicit result count and backend query. Don't infer cross-page selection from the visible IDs.

***

## Compose row interaction

| Polaris React | Polaris web components | Migration notes |
| - | - | - |
| `IndexTable.Row` | `s-table-row` | Keep a stable `key` and resource ID in app state. |
| Row `selected` | `checked` on a row `s-checkbox` | Derive it from `selectedIds.has(id)`. |
| `onSelectionChange` | Checkbox `onChange` handlers | Update the same selection set for row and page controls. 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. |
| Row navigation | `s-link` in the primary cell | Preserve a real resource URL. |
| Clickable row | `clickDelegate` pointing to the checkbox ID | Delegate row interaction without removing the checkbox's accessible control. |
| `headings` | `s-table-header-row` and `s-table-header` | Render headings and assign responsive `listSlot` values. |
| `IndexTable.Cell` | `s-table-cell` | Keep cell order aligned with its header. |

***

## Compose bulk actions

When `selectedIds.size` is greater than zero, replace the normal filter controls with a bulk-action area in the table's `filters` slot. Display the selected count and explicit `s-button` controls. Keep the count and actions together in a responsive toolbar so they wrap instead of colliding at a narrow app width.

Pass the selected IDs to the backend operation, show pending state on the action, and handle success and failure. After success, clear selection, refresh the data, and show confirmation. After failure, preserve selection so that the user can retry.

Don't store selection only in checkbox elements. The same app state must drive the selected count, action payload, select-all calculation, and post-action cleanup.

***

## Rebuild sorting

`s-table` doesn't implement `sortable`, `defaultSortDirection`, `onSort`, or a sort event. Render an explicit sort control, store a stable value such as `title-asc` or `updated-desc` in the same URL or route state as the filters, and translate that value into the backend query.

The example uses an `s-select` in the table's `filters` slot. Changing it clears the previous cursor and selected IDs before loading the newly ordered results. The table header row isn't rendered in the narrow list layout, so don't put the only sort control in a header. If sortable column headings are an additional wide-layout affordance, each header button should update the same sort value and only the active column should show its direction.

***

## Keep filters, sorting, and pagination together

The example stores `query`, `status`, `sort`, cursor, and direction in URL search parameters. Each change updates the backend query and clears selection. Applied filters render as removable `s-clickable-chip` elements.

Keep one source of truth for:

* Search and applied filter values.
* Sort choice and backend sort parameter.
* Cursor-based pagination and loading state.
* The visible result IDs and selected ID set.
* Empty, error, and bulk-action results.

Reset pagination when search, filters, or sort changes. Don't reuse a cursor from a different query.

***

## Test the migration

* Select and deselect individual rows, the full page, and a partial page.
* Change filters or sort order with selected rows, and verify the intended selection reset.
* Reload and use browser navigation after sorting, and confirm that the selected order is restored from URL state.
* Run each bulk action through success and failure, including repeated submission protection.
* Navigate forward and backward through results, and verify cursor and URL state.
* Test empty, loading, and error states at narrow and wide app viewport sizes.
* Confirm that sorting remains available in the narrow list layout; header-row select-all isn't available there, so verify the bulk-action bar provides it when the app requires narrow-layout select-all.
* Verify row links, checkbox labels, header relationships, and keyboard focus order.

***

## Remove Polaris React

After the complete data view is migrated, remove `IndexTable`, `useIndexResourceState`, Polaris bulk-action descriptors, and compatibility helpers used only by them. 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)
* [Table component](https://shopify.dev/docs/api/app-home/web-components/layout-and-structure/table)
* [Migrate Filters from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/filters)
* [Migrate UnstableBulkActions from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/unstable-bulk-actions)

***
