Migrate Index Filters from Polaris React
Polaris web components don't have a descriptor-based IndexFilters component. Compose saved-view, search, filter, and sort controls around the same URL or route state used by the resource query.
Use this composition with the resource index template. Migrate the controls and their table, selection, pagination, and backend query as one feature.
Anchor to Migrate IndexFilters controlsMigrate Index Filters controls
The following example makes saved-view persistence explicit. Selecting a saved view restores its query parameters, changing an individual filter exits that saved view, and creating a view persists the current parameters through the app's backend.
Migrating product index filters
Polaris web components
import {useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router';
function ProductIndexFilters({savedViews, createSavedView, searchProducts}) {
const [searchParams, setSearchParams] = useSearchParams();
const [viewName, setViewName] = useState('');
const [savePending, setSavePending] = useState(false);
const [resultCount, setResultCount] = useState<number | null>(null);
const [searchError, setSearchError] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const latestRequest = useRef(0);
const createViewPopover = useRef<
HTMLElementTagNameMap['s-popover'] & {hideOverlay: () => void}
>(null);
const activeView = searchParams.get('view') ?? 'all';
const query = searchParams.get('query') ?? '';
const status = searchParams.get('status') ?? 'all';
const sort = searchParams.get('sort') ?? 'updated-desc';
const hasCustomParameters = ['query', 'status', 'sort'].some((name) =>
searchParams.has(name),
);
const selectedView =
!searchParams.has('view') && hasCustomParameters ? 'unsaved' : activeView;
useEffect(() => {
const controller = new AbortController();
const requestId = ++latestRequest.current;
const timeout = setTimeout(async () => {
try {
const result = await searchProducts({
query,
status,
sort,
signal: controller.signal,
});
if (requestId === latestRequest.current) {
setResultCount(result.totalCount);
setSearchError(false);
}
} catch (error) {
if (
!controller.signal.aborted &&
requestId === latestRequest.current
) {
setSearchError(true);
}
}
}, 300);
return () => {
clearTimeout(timeout);
controller.abort();
};
}, [query, retryCount, searchProducts, sort, status]);
function updateQuery(patch: Record<string, string | null>) {
const next = new URLSearchParams(searchParams);
next.delete('cursor');
next.delete('view');
for (const [name, value] of Object.entries(patch)) {
if (value && value !== 'all') {
next.set(name, value);
} else {
next.delete(name);
}
}
setSearchParams(next, {replace: true});
}
function applySavedView(viewId: string) {
if (viewId === 'unsaved') return;
if (viewId === 'all') {
setSearchParams(new URLSearchParams(), {replace: true});
return;
}
const view = savedViews.find((candidate) => candidate.id === viewId);
if (!view) return;
const next = new URLSearchParams(view.parameters);
next.set('view', view.id);
setSearchParams(next, {replace: true});
}
async function saveCurrentView() {
if (!viewName.trim()) return;
setSavePending(true);
try {
const parameters: Record<string, string> = {};
searchParams.forEach((value, name) => {
parameters[name] = value;
});
delete parameters.cursor;
delete parameters.view;
const view = await createSavedView({
name: viewName.trim(),
parameters,
});
const next = new URLSearchParams(parameters);
next.set('view', view.id);
setSearchParams(next, {replace: true});
setViewName('');
createViewPopover.current?.hideOverlay();
shopify.toast.show('View saved');
} catch {
shopify.toast.show("View couldn't be saved", {isError: true});
} finally {
setSavePending(false);
}
}
return (
<s-query-container>
<s-stack gap="small">
<s-grid
gap="small"
alignItems="end"
gridTemplateColumns="repeat(auto-fit, minmax(12rem, 1fr))"
>
<s-select
label="Saved view"
value={selectedView}
onChange={(event) => applySavedView(event.currentTarget.value)}
>
<s-option value="all">All products</s-option>
{selectedView === 'unsaved' && (
<s-option value="unsaved">Custom</s-option>
)}
{savedViews.map((view) => (
<s-option key={view.id} value={view.id}>
{view.name}
</s-option>
))}
</s-select>
<s-search-field
label="Search"
value={query}
onInput={(event) =>
updateQuery({query: event.currentTarget.value || null})
}
/>
<s-stack direction="inline" gap="small">
<s-button commandFor="product-filter-popover">
{status === 'all' ? 'Filter and sort' : 'Filters (1)'}
</s-button>
<s-button commandFor="create-view-popover" variant="tertiary">
Save as view
</s-button>
</s-stack>
</s-grid>
<s-popover id="product-filter-popover" inlineSize="280px">
<s-box padding="base">
<s-stack gap="base">
<s-select
label="Status"
value={status}
onChange={(event) =>
updateQuery({status: 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-option value="archived">Archived</s-option>
</s-select>
<s-select
label="Sort by"
value={sort}
onChange={(event) =>
updateQuery({sort: event.currentTarget.value})
}
>
<s-option value="updated-desc">Recently updated</s-option>
<s-option value="title-asc">Title, A–Z</s-option>
<s-option value="title-desc">Title, Z–A</s-option>
</s-select>
<s-button
commandFor="product-filter-popover"
command="--hide"
>
Done
</s-button>
</s-stack>
</s-box>
</s-popover>
<s-popover
ref={createViewPopover}
id="create-view-popover"
inlineSize="320px"
>
<s-box padding="base">
<s-stack gap="base">
<s-text-field
label="View name"
value={viewName}
error={!viewName.trim() ? 'Enter a view name' : undefined}
onInput={(event) => setViewName(event.currentTarget.value)}
/>
<s-button-group>
<s-button
slot="secondary-actions"
commandFor="create-view-popover"
command="--hide"
onClick={() => setViewName('')}
>
Cancel
</s-button>
<s-button
slot="primary-action"
variant="primary"
loading={savePending}
disabled={!viewName.trim()}
onClick={saveCurrentView}
>
Save view
</s-button>
</s-button-group>
</s-stack>
</s-box>
</s-popover>
{(query || status !== 'all') && (
<s-stack direction="inline" gap="small">
{query && (
<s-clickable-chip
removable
accessibilityLabel="Remove search filter"
onRemove={() => updateQuery({query: null})}
>
Search: {query}
</s-clickable-chip>
)}
{status !== 'all' && (
<s-clickable-chip
removable
accessibilityLabel="Remove status filter"
onRemove={() => updateQuery({status: null})}
>
Status: {status}
</s-clickable-chip>
)}
<s-button
variant="tertiary"
onClick={() => updateQuery({query: null, status: null})}
>
Clear all
</s-button>
</s-stack>
)}
<s-box accessibilityRole="status">
{searchError ? (
<s-stack direction="inline" gap="small" alignItems="center">
<s-text>Products couldn't be loaded.</s-text>
<s-button onClick={() => setRetryCount((count) => count + 1)}>
Retry
</s-button>
</s-stack>
) : resultCount !== null ? (
<s-text color="subdued">{resultCount} products</s-text>
) : null}
</s-box>
</s-stack>
</s-query-container>
);
}Polaris React
import {IndexFilters, useSetIndexFiltersMode} from '@shopify/polaris';
export function ProductIndexFilters({
views,
createView,
statusFilters,
appliedStatusFilters,
clearAll,
}) {
const {mode, setMode} = useSetIndexFiltersMode();
const [selectedView, setSelectedView] = useState(0);
const [query, setQuery] = useState('');
const [sortSelected, setSortSelected] = useState(['updated desc']);
return (
<IndexFilters
tabs={views.map((view) => ({
id: view.id,
content: view.name,
onAction: () => view.apply(),
}))}
selected={selectedView}
onSelect={setSelectedView}
canCreateNewView
onCreateNewView={createView}
queryValue={query}
queryPlaceholder="Search products"
onQueryChange={setQuery}
onQueryClear={() => setQuery('')}
sortOptions={[
{label: 'Recently updated', value: 'updated desc'},
{label: 'Title, A to Z', value: 'title asc'},
{label: 'Title, Z to A', value: 'title desc'},
]}
sortSelected={sortSelected}
onSort={setSortSelected}
mode={mode}
setMode={setMode}
primaryAction={{type: 'save', onAction: createView}}
cancelAction={{onAction: () => setMode('filtering')}}
filters={statusFilters}
appliedFilters={appliedStatusFilters}
onClearAll={clearAll}
/>
);
}Preview
Anchor to Replace IndexFilters propertiesReplace Index Filters properties
| Polaris React | Polaris web components | Migration notes |
|---|---|---|
tabs, selected, and onSelect | A saved-view s-select, or explicit navigation controls when a small set must stay visible | Store a stable view ID, not an array index. |
canCreateNewView and onCreateNewView | A Save as view button and a named form in an s-popover | Persist the view name and normalized query parameters through the app's backend. |
primaryAction and cancelAction | Explicit save and cancel s-button controls | Keep pending, validation, success, and failure states in app state. |
queryValue, onQueryChange, and onQueryClear | value and onInput on s-search-field | Reset the cursor and selected resources when the query changes. |
filters | Explicit s-select, s-choice-list, date, or other form controls | Match each control to its value type. Put secondary controls in a labeled s-popover instead of forcing every control into a fixed grid. |
appliedFilters and onClearAll | Removable s-clickable-chip elements and a clear-all button | Derive chips from the active query parameters. |
sortOptions, sortSelected, and onSort | An s-select with stable backend sort values | Reset pagination when result order changes. |
mode and setMode | Local form visibility state, if the page still needs an edit flow | Don't preserve filtering/editing modes when visible controls are simpler. |
disableQueryField, hideQueryField, and disabled | Conditionally render or disable the corresponding explicit controls | Preserve the product rule that caused the old state. |
isFlushWhenSticky and fullWidthAction | Remove | Let the page and resource-index layout own spacing and width. |
Keep labels visible for inputs that share a toolbar row, and bottom-align adjacent action buttons with those inputs. Let the responsive grid stack the controls instead of mixing visible and accessibility-only labels.
Anchor to Define saved-view behaviorDefine saved-view behavior
A saved view should persist stable, backend-understood values such as status=active and sort=updated-desc. Don't persist rendered labels, a selected tab index, a cursor, or a React filter descriptor.
When a merchant selects a view:
- Load the view by its stable ID.
- Replace the active search, filter, and sort parameters with the saved parameters.
- Reset pagination and selected resources.
- Load the resource results for that query.
- Keep the view ID in the URL if the route needs to restore the selected view.
When a merchant changes a saved view's filter, either mark the view as modified or leave the saved view and treat the query as custom. The example removes the view ID and displays Custom, while keeping All products as a separate option that clears the query.
For rename, duplicate, and delete operations, add a contextual menu next to the selected view and persist those operations through the backend. If the app doesn't support saved views today, omit this feature instead of creating browser-only views that disappear on another device.
Anchor to Keep query and table state togetherKeep query and table state together
Keep one source of truth for the saved view ID, filters, sort, cursor, and selected IDs. The URL can own restorable values; component state can own transient UI such as a pending view save and result feedback.
Prevent an older request from replacing results for a newer query. The example waits 300 milliseconds after a control changes, aborts the previous request during effect cleanup, and compares a monotonically increasing request ID before applying the response. The request ID still protects the UI if a client or backend ignores the abort signal. A route loader can provide equivalent cancellation and stale-response protection.
On request failure, keep the chosen controls visible and provide a retry path rather than reverting the UI silently. The example's Retry button increments a transient counter so the effect repeats the current request without changing its parameters. See the more detailed debounce and cancellation pattern in the Filters migration.
Anchor to Test the migrationTest the migration
- Select each saved view and confirm that the URL, controls, backend request, and results agree.
- Modify a saved view and verify the UI no longer misidentifies it as unchanged.
- Create, validate, cancel, retry, rename, and delete saved views where supported.
- Search, combine filters, clear values, and change sort from a later result page.
- Reload and navigate with browser history to verify state restoration.
- Verify focus order, control labels, chip removal, and behavior at a narrow app viewport.
Anchor to Remove Polaris ReactRemove Polaris React
After the entire resource index is migrated, remove IndexFilters, useSetIndexFiltersMode, tab and action descriptor builders, and duplicate filter state. Remove @shopify/polaris only after no other route in scope imports it.
- Resource index template
- Migrate Filters from Polaris React
- Migrate IndexTable from Polaris React
- Select component