Skip to main content

Migrate IndexTable from Polaris React

Replace Polaris React IndexTable with the index table pattern: 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.


Anchor to Migrate an index tableMigrate 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

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>
);
}
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>
);
}

Preview

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.


Anchor to Rebuild selection stateRebuild 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.


Anchor to Compose row interactionCompose row interaction

Polaris ReactPolaris web componentsMigration notes
IndexTable.Rows-table-rowKeep a stable key and resource ID in app state.
Row selectedchecked on a row s-checkboxDerive it from selectedIds.has(id).
onSelectionChangeCheckbox onChange handlersUpdate 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 navigations-link in the primary cellPreserve a real resource URL.
Clickable rowclickDelegate pointing to the checkbox IDDelegate row interaction without removing the checkbox's accessible control.
headingss-table-header-row and s-table-headerRender headings and assign responsive listSlot values.
IndexTable.Cells-table-cellKeep cell order aligned with its header.

Anchor to Compose bulk actionsCompose 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.


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.


Anchor to Keep filters, sorting, and pagination togetherKeep 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.


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

Anchor to Remove Polaris ReactRemove 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.



Was this page helpful?