---
title: Migrate Filters from Polaris React
description: >-
  Replace the Polaris React Filters component with explicit search, select, and
  removable-filter controls backed by one source of truth.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/filters
  md: >-
    https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/filters.md
api_name: app-home
---

# Migrate Filters from Polaris React

Polaris web components don't have a descriptor-based `Filters` component. Compose [`s-search-field`](https://shopify.dev/docs/api/app-home/web-components/forms/search-field), form controls such as `s-select`, and removable `s-clickable-chip` elements around the same state that loads the resource view.

Use the [resource index template](https://shopify.dev/docs/api/app-home/patterns/templates/resource-index) when filters belong to a complete index page.

***

## Migrate search and filters

The following example stores applied values in URL search parameters. Changing or removing a filter clears the existing pagination cursor before the app reloads results.

## Migrating product filters

##### Polaris web components

```tsx
import {useEffect} from 'react';
import {useSearchParams} from 'react-router';

function ProductFilters({onResults, onRequestError}) {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('query') ?? '';
  const status = searchParams.get('status') ?? 'all';
  const requestKey = searchParams.toString();

  useEffect(() => {
    const controller = new AbortController();
    const timeout = window.setTimeout(async () => {
      try {
        const response = await fetch(`/api/products?${requestKey}`, {
          signal: controller.signal,
        });
        if (!response.ok) throw new Error('Product search failed');
        onResults(await response.json());
      } catch (error) {
        if (error instanceof DOMException && error.name === 'AbortError') return;
        onRequestError(error);
      }
    }, 250);

    return () => {
      window.clearTimeout(timeout);
      controller.abort();
    };
  }, [requestKey, onResults]);

  function updateFilter(name: string, value: string | null) {
    const next = new URLSearchParams(searchParams);
    next.delete('cursor');
    next.delete(name);

    if (value) next.set(name, value);

    setSearchParams(next, {replace: true});
  }

  function clearAll() {
    const next = new URLSearchParams(searchParams);
    next.delete('query');
    next.delete('status');
    next.delete('cursor');
    setSearchParams(next, {replace: true});
  }

  return (
    <s-stack gap="small-100">
      <s-grid
        gap="small"
        alignItems="end"
        gridTemplateColumns="repeat(auto-fit, minmax(12rem, 1fr))"
      >
        <s-search-field
          label="Search"
          value={query}
          onInput={(event) =>
            updateFilter('query', event.currentTarget.value || null)
          }
        />
        <s-select
          label="Status"
          value={status}
          onChange={(event) =>
            updateFilter(
              '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-grid>
      {(query || status !== 'all') && (
        <s-stack direction="inline" gap="small">
          {query && (
            <s-clickable-chip
              removable
              accessibilityLabel="Remove search filter"
              onRemove={() => updateFilter('query', null)}
            >
              Search: {query}
            </s-clickable-chip>
          )}
          {status !== 'all' && (
            <s-clickable-chip
              removable
              accessibilityLabel={`Remove ${status} status filter`}
              onRemove={() => updateFilter('status', null)}
            >
              Status: {status}
            </s-clickable-chip>
          )}
          <s-button variant="tertiary" onClick={clearAll}>
            Clear all
          </s-button>
        </s-stack>
      )}
    </s-stack>
  );
}
```

##### Polaris React

```tsx
import {ChoiceList, Filters} from '@shopify/polaris';

export function ProductFilters({
  query,
  status,
  onQueryChange,
  onStatusChange,
  onClearAll,
}) {
  const filters = [
    {
      key: 'status',
      label: 'Status',
      filter: (
        <ChoiceList
          title="Status"
          titleHidden
          choices={[
            {label: 'Active', value: 'active'},
            {label: 'Draft', value: 'draft'},
          ]}
          selected={status}
          onChange={onStatusChange}
        />
      ),
    },
  ];

  const appliedFilters = status.length
    ? [{key: 'status', label: `Status: ${status[0]}`, onRemove: () => onStatusChange([])}]
    : [];

  return (
    <Filters
      queryValue={query}
      queryPlaceholder="Search products"
      onQueryChange={onQueryChange}
      onQueryClear={() => onQueryChange('')}
      filters={filters}
      appliedFilters={appliedFilters}
      onClearAll={onClearAll}
    />
  );
}
```

***

## Replace filter descriptors

| Polaris React | Polaris web components | Migration notes |
| - | - | - |
| `queryValue` | `value` on `s-search-field` | Read from the same source of truth used by the backend request. |
| `onQueryChange` | `onInput` | Update the query and reset pagination. |
| `onQueryClear` | Set the query value to `null` or an empty string | Remove the URL parameter and reload results. |
| `queryPlaceholder` | `placeholder` | Keep an accessible `label`, even when it is visually exclusive. |
| `filters` | Explicit `s-select`, `s-choice-list`, date, or other field components | Choose a control that matches each value rather than rendering descriptor objects. |
| `appliedFilters` | Removable `s-clickable-chip` elements | Render the applied value and remove it from the same query state. |
| `onClearAll` | One handler that clears all filter parameters | Also reset pagination and selection. |
| `disabled` | `disabled` on each control | Preserve the condition on the controls that it affects. |

Don't keep a parallel `appliedFilters` array that can drift from backend parameters. Derive chips from the current query values.

***

## Keep one source of truth

When a filter changes:

1. Update its URL or route-state value.
2. Remove pagination cursors that belong to the previous query.
3. Clear selected resource IDs that no longer have a defined relationship to the new results.
4. Load results with the updated search, filter, and sort parameters.
5. Render loading, empty, or error feedback for that request.

***

## Debounce and cancel remote requests

The example updates the visible field and URL immediately, then waits 250 milliseconds before requesting results. Its effect creates an `AbortController`; cleanup clears the pending timer and aborts the previous fetch whenever the query changes. An aborted response is ignored, so it can't replace results for a newer query.

Keep loading and error state beside the results that the request owns. If your data framework already cancels route loaders, use that mechanism instead of adding a second fetch, but verify the same rapid-input behavior.

***

## Choose visible controls or a popover

Keep common filters visible beside search. Use a responsive grid with visible labels and bottom-aligned fields, then place applied chips directly below it. Use `s-popover` for secondary filter controls when space is constrained, but keep applied values visible as removable chips. Don't hide every filter in an overflow solely to match the old component's layout.

Use a self-contained label for every control. Preserve validation, date boundaries, option values, and serialization rules from the previous filter implementation.

For multi-value filters, use a stable repeated-key convention such as `status=active&status=draft`. Read it with `searchParams.getAll('status')`, replace all values together when the choice list changes, and derive one removable chip per value. If the backend expects another format, such as a comma-separated value, keep that format consistent across URL restoration, saved views, loader input, and chip removal.

***

## Test the migration

* Apply, remove, and clear every filter independently and in combination.
* Reload and navigate through browser history, and verify that URL state restores the same results.
* Change filters from a later page, and confirm that pagination resets.
* Verify selection clears or reconciles according to the data-view rules.
* Test slow, failed, empty, and out-of-order backend responses.
* Verify labels, focus order, and chip removal with a keyboard and screen reader.

***

## Remove Polaris React

After every `Filters` call site is migrated, remove descriptor builders, duplicate applied-filter state, and the `Filters` import. 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)
* [Search field component](https://shopify.dev/docs/api/app-home/web-components/forms/search-field)
* [Migrate IndexTable from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/index-table)
* [Migrate Popover from Polaris React](https://shopify.dev/docs/apps/build/app-home/migrate-from-polaris-react/popover)

***
