Skip to main content

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

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

Polaris ReactPolaris web componentsMigration notes
tabs, selected, and onSelectA saved-view s-select, or explicit navigation controls when a small set must stay visibleStore a stable view ID, not an array index.
canCreateNewView and onCreateNewViewA Save as view button and a named form in an s-popoverPersist the view name and normalized query parameters through the app's backend.
primaryAction and cancelActionExplicit save and cancel s-button controlsKeep pending, validation, success, and failure states in app state.
queryValue, onQueryChange, and onQueryClearvalue and onInput on s-search-fieldReset the cursor and selected resources when the query changes.
filtersExplicit s-select, s-choice-list, date, or other form controlsMatch 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 onClearAllRemovable s-clickable-chip elements and a clear-all buttonDerive chips from the active query parameters.
sortOptions, sortSelected, and onSortAn s-select with stable backend sort valuesReset pagination when result order changes.
mode and setModeLocal form visibility state, if the page still needs an edit flowDon't preserve filtering/editing modes when visible controls are simpler.
disableQueryField, hideQueryField, and disabledConditionally render or disable the corresponding explicit controlsPreserve the product rule that caused the old state.
isFlushWhenSticky and fullWidthActionRemoveLet 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:

  1. Load the view by its stable ID.
  2. Replace the active search, filter, and sort parameters with the saved parameters.
  3. Reset pagination and selected resources.
  4. Load the resource results for that query.
  5. 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.


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



Was this page helpful?