---
title: Migrate IndexFilters from Polaris React
description: >-
  Replace Polaris React IndexFilters with explicit saved-view, search, filter,
  sort, and view-creation controls backed by one source of truth.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-filters
  md: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-filters.md
api_name: app-home
---

# 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](https://shopify.dev/docs/api/app-home/patterns/templates/resource-index). Migrate the controls and their table, selection, pagination, and backend query as one feature.

***

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

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

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

***

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

***

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

***

## Keep 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](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/filters#debounce-and-cancel-remote-requests).

***

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

***

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

***

## Related guidance

* [Resource index template](https://shopify.dev/docs/api/app-home/patterns/templates/resource-index)
* [Migrate Filters from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/filters)
* [Migrate IndexTable from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-table)
* [Select component](https://shopify.dev/docs/api/app-home/web-components/forms/select)

***
