Filter data in a table

const [typedChips, setTypedChips] = useState<string[]>([]);
const [inputValue, setInputValue] = useState("");
const [inputError, setInputError] = useState("");
const errorEmpty = "Empty filter";
const errorDuplicate = "Enter a unique filter";

const data = useMemo(
  () => [
    {
      status: { type: "information" as GoabBadgeType, text: "In progress" },
      name: "Ivan Schmidt",
      id: "7838576954",
    },
    {
      status: { type: "success" as GoabBadgeType, text: "Completed" },
      name: "Luz Lakin",
      id: "8576953364",
    },
    {
      status: { type: "information" as GoabBadgeType, text: "In progress" },
      name: "Keith McGlynn",
      id: "9846041345",
    },
    {
      status: { type: "success" as GoabBadgeType, text: "Completed" },
      name: "Melody Frami",
      id: "7385256175",
    },
    {
      status: { type: "important" as GoabBadgeType, text: "Updated" },
      name: "Frederick Skiles",
      id: "5807570418",
    },
    {
      status: { type: "success" as GoabBadgeType, text: "Completed" },
      name: "Dana Pfannerstill",
      id: "5736306857",
    },
  ],
  [],
);

const [dataFiltered, setDataFiltered] = useState(data);

const handleInputChange = (detail: GoabInputOnChangeDetail) => {
  const newValue = detail.value.trim();
  setInputValue(newValue);
};

const handleInputKeyPress = (detail: GoabInputOnKeyPressDetail) => {
  if (detail.key === "Enter") {
    applyFilter();
  }
};

const applyFilter = () => {
  if (inputValue === "") {
    setInputError(errorEmpty);
    return;
  }
  if (typedChips.length > 0 && typedChips.includes(inputValue)) {
    setInputError(errorDuplicate);
    return;
  }
  setTypedChips([...typedChips, inputValue]);
  setTimeout(() => {
    setInputValue("");
  }, 0);
  setInputError("");
};

const removeTypedChip = (chip: string) => {
  setTypedChips(typedChips.filter((c) => c !== chip));
  setInputError("");
};

const checkNested = useCallback((obj: object, chip: string): boolean => {
  return Object.values(obj).some((value) =>
    typeof value === "object" && value !== null
      ? checkNested(value, chip)
      : typeof value === "string" && value.toLowerCase().includes(chip.toLowerCase()),
  );
}, []);

const getFilteredData = useCallback(
  (typedChips: string[]) => {
    if (typedChips.length === 0) {
      return data;
    }
    return data.filter((item: object) =>
      typedChips.every((chip) => checkNested(item, chip)),
    );
  },
  [checkNested, data],
);

useEffect(() => {
  setDataFiltered(getFilteredData(typedChips));
}, [getFilteredData, typedChips]);
<GoabFormItem id="filterChipInput" error={inputError} mb="m">
  <GoabBlock gap="xs" direction="row" alignment="start" width="100%">
    <div style={{ flex: 1 }}>
      <GoabInput
        name="filterChipInput"
        aria-labelledby="filterChipInput"
        value={inputValue}
        leadingIcon="search"
        width="100%"
        onChange={handleInputChange}
        onKeyPress={handleInputKeyPress}
      />
    </div>
    <GoabButton type="secondary" onClick={applyFilter} leadingIcon="filter">
      Filter
    </GoabButton>
  </GoabBlock>
</GoabFormItem>

{typedChips.length > 0 && (
  <div>
    <GoabText tag="span" color="secondary" mb="xs" mr="xs">
      Filter:
    </GoabText>
    {typedChips.map((typedChip, index) => (
      <GoabFilterChip
        key={index}
        content={typedChip}
        mb="xs"
        mr="xs"
        onClick={() => removeTypedChip(typedChip)}
      />
    ))}
    <GoabButton
      type="tertiary"
      size="compact"
      mb="xs"
      onClick={() => setTypedChips([])}
    >
      Clear all
    </GoabButton>
  </div>
)}

<GoabTable width="100%">
  <thead>
    <tr>
      <th>Status</th>
      <th>Name</th>
      <th className="goa-table-number-header">ID Number</th>
    </tr>
  </thead>
  <tbody>
    {dataFiltered.map((item) => (
      <tr key={item.id}>
        <td>
          <GoabBadge
            type={item.status.type}
            content={item.status.text}
            icon={false}
          />
        </td>
        <td>{item.name}</td>
        <td className="goa-table-number-column">{item.id}</td>
      </tr>
    ))}
  </tbody>
</GoabTable>

{dataFiltered.length === 0 && data.length > 0 && (
  <GoabBlock mt="l" mb="l">
    No results found
  </GoabBlock>
)}
typedChips: string[] = [];
inputValue = "";
inputError = "";
readonly errorEmpty = "Empty filter";
readonly errorDuplicate = "Enter a unique filter";

readonly data: DataItem[] = [
  {
    status: { type: "information", text: "In progress" },
    name: "Ivan Schmidt",
    id: "7838576954",
  },
  {
    status: { type: "success", text: "Completed" },
    name: "Luz Lakin",
    id: "8576953364",
  },
  {
    status: { type: "information", text: "In progress" },
    name: "Keith McGlynn",
    id: "9846041345",
  },
  {
    status: { type: "success", text: "Completed" },
    name: "Melody Frami",
    id: "7385256175",
  },
  {
    status: { type: "important", text: "Updated" },
    name: "Frederick Skiles",
    id: "5807570418",
  },
  {
    status: { type: "success", text: "Completed" },
    name: "Dana Pfannerstill",
    id: "5736306857",
  },
];

dataFiltered = this.getFilteredData(this.typedChips);

handleInputChange(detail: GoabInputOnChangeDetail): void {
  const newValue = detail.value.trim();
  this.inputValue = newValue;
}

handleInputKeyPress(detail: GoabInputOnKeyPressDetail): void {
  if (detail.key === "Enter") {
    this.applyFilter();
  }
}

applyFilter(): void {
  if (this.inputValue === "") {
    this.inputError = this.errorEmpty;
    return;
  }
  if (this.typedChips.includes(this.inputValue)) {
    this.inputError = this.errorDuplicate;
    return;
  }
  this.typedChips = [...this.typedChips, this.inputValue];
  this.inputValue = "";
  this.inputError = "";
  this.dataFiltered = this.getFilteredData(this.typedChips);
}

removeTypedChip(chip: string): void {
  this.typedChips = this.typedChips.filter((c) => c !== chip);
  this.dataFiltered = this.getFilteredData(this.typedChips);
  this.inputError = "";
}

removeAllTypedChips(): void {
  this.typedChips = [];
  this.dataFiltered = this.getFilteredData(this.typedChips);
  this.inputError = "";
}

getFilteredData(typedChips: string[]): DataItem[] {
  if (typedChips.length === 0) {
    return this.data;
  }
  return this.data.filter((item) =>
    typedChips.every((chip) => this.checkNested(item, chip)),
  );
}

checkNested(obj: object, chip: string): boolean {
  return Object.values(obj).some((value) =>
    typeof value === "object" && value !== null
      ? this.checkNested(value, chip)
      : typeof value === "string" && value.toLowerCase().includes(chip.toLowerCase()),
  );
}
<goab-form-item id="filterChipInput" [error]="inputError" mb="m">
  <goab-block gap="xs" direction="row" alignment="start" width="100%">
    <div style="flex: 1">
      <goab-input
        name="filterChipInput"
        aria-labelledby="filterChipInput"
        [value]="inputValue"
        leadingIcon="search"
        width="100%"
        (onChange)="handleInputChange($event)"
        (onKeyPress)="handleInputKeyPress($event)"
      >
      </goab-input>
    </div>
    <goab-button type="secondary" (onClick)="applyFilter()" leadingIcon="filter">
      Filter
    </goab-button>
  </goab-block>
</goab-form-item>

@if (typedChips.length > 0) {
<ng-container>
  <goab-text tag="span" color="secondary" mb="xs" mr="xs"> Filter: </goab-text>
  @for (typedChip of typedChips; track typedChip; let index = $index) {
  <goab-filter-chip
    [content]="typedChip"
    mb="xs"
    mr="xs"
    (onClick)="removeTypedChip(typedChip)"
  >
  </goab-filter-chip>
  }
  <goab-button type="tertiary" size="compact" mb="xs" (onClick)="removeAllTypedChips()">
    Clear all
  </goab-button>
</ng-container>
}

<goab-table width="100%">
  <thead>
    <tr>
      <th>Status</th>
      <th>Name</th>
      <th class="goa-table-number-header">ID Number</th>
    </tr>
  </thead>
  <tbody>
    @for (item of dataFiltered; track $index) {
    <tr>
      <td>
        <goab-badge
          [type]="item.status.type"
          [content]="item.status.text"
          [icon]="false"
        ></goab-badge>
      </td>
      <td>{{ item.name }}</td>
      <td class="goa-table-number-column">{{ item.id }}</td>
    </tr>
    }
  </tbody>
</goab-table>

@if (dataFiltered.length === 0 && data.length > 0) {
<goab-block mt="l" mb="l"> No results found </goab-block>
}
const filterInput = document.getElementById("filter-input");
const filterBtn = document.getElementById("filter-btn");
const filterFormItem = document.getElementById("filter-form-item");
const chipsContainer = document.getElementById("chips-container");
const chipsList = document.getElementById("chips-list");
const clearAllBtn = document.getElementById("clear-all-btn");
const tableRows = document.querySelectorAll("tbody tr");

let typedChips = [];

function filterTable() {
  tableRows.forEach((row) => {
    const badge = row.querySelector("goa-badge");
    const badgeText = badge ? badge.getAttribute("content") || "" : "";
    const text = (row.textContent + " " + badgeText).toLowerCase();
    const matches =
      typedChips.length === 0 ||
      typedChips.every((chip) => text.includes(chip.toLowerCase()));
    row.style.display = matches ? "" : "none";
  });
}

function renderChips() {
  chipsList.innerHTML = "";
  typedChips.forEach((chip) => {
    const filterChip = document.createElement("goa-filter-chip");
    filterChip.setAttribute("version", "2");
    filterChip.setAttribute("content", chip);
    filterChip.setAttribute("mb", "xs");
    filterChip.setAttribute("mr", "xs");
    filterChip.addEventListener("_click", () => removeChip(chip));
    chipsList.appendChild(filterChip);
  });
  chipsContainer.style.display = typedChips.length > 0 ? "block" : "none";
  filterTable();
}

function applyFilter() {
  const value = filterInput.value.trim();
  if (value === "") {
    filterFormItem.setAttribute("error", "Empty filter");
    return;
  }
  if (typedChips.includes(value)) {
    filterFormItem.setAttribute("error", "Enter a unique filter");
    return;
  }
  typedChips.push(value);
  filterInput.value = "";
  filterFormItem.removeAttribute("error");
  renderChips();
}

function removeChip(chip) {
  typedChips = typedChips.filter((c) => c !== chip);
  renderChips();
}

filterBtn.addEventListener("_click", applyFilter);
clearAllBtn.addEventListener("_click", () => {
  typedChips = [];
  renderChips();
});
<goa-form-item version="2" id="filter-form-item" mb="m">
  <goa-block gap="xs" direction="row" alignment="center" width="100%">
    <div style="flex: 1">
      <goa-input
        version="2"
        id="filter-input"
        name="filterChipInput"
        leadingicon="search"
        width="100%"
      >
      </goa-input>
    </div>
    <goa-button version="2" id="filter-btn" type="secondary" leadingicon="filter">
      Filter
    </goa-button>
  </goa-block>
</goa-form-item>

<div id="chips-container" style="display: none">
  <goa-text as="span" color="secondary" mb="xs" mr="xs">Filter:</goa-text>
  <span id="chips-list"></span>
  <goa-button version="2" id="clear-all-btn" type="tertiary" size="compact" mb="xs">
    Clear all
  </goa-button>
</div>

<goa-table version="2" width="100%" mt="s">
  <table style="width: 100%">
    <thead>
      <tr>
        <th>Status</th>
        <th>Name</th>
        <th class="goa-table-number-header">ID Number</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>
          <goa-badge
            version="2"
            type="information"
            content="In progress"
            icon="false"
          ></goa-badge>
        </td>
        <td>Ivan Schmidt</td>
        <td class="goa-table-number-column">7838576954</td>
      </tr>
      <tr>
        <td>
          <goa-badge
            version="2"
            type="success"
            content="Completed"
            icon="false"
          ></goa-badge>
        </td>
        <td>Luz Lakin</td>
        <td class="goa-table-number-column">8576953364</td>
      </tr>
      <tr>
        <td>
          <goa-badge
            version="2"
            type="information"
            content="In progress"
            icon="false"
          ></goa-badge>
        </td>
        <td>Keith McGlynn</td>
        <td class="goa-table-number-column">9846041345</td>
      </tr>
      <tr>
        <td>
          <goa-badge
            version="2"
            type="success"
            content="Completed"
            icon="false"
          ></goa-badge>
        </td>
        <td>Melody Frami</td>
        <td class="goa-table-number-column">7385256175</td>
      </tr>
      <tr>
        <td>
          <goa-badge
            version="2"
            type="important"
            content="Updated"
            icon="false"
          ></goa-badge>
        </td>
        <td>Frederick Skiles</td>
        <td class="goa-table-number-column">5807570418</td>
      </tr>
      <tr>
        <td>
          <goa-badge
            version="2"
            type="success"
            content="Completed"
            icon="false"
          ></goa-badge>
        </td>
        <td>Dana Pfannerstill</td>
        <td class="goa-table-number-column">5736306857</td>
      </tr>
    </tbody>
  </table>
</goa-table>

Enable users to filter table data using search input and filter chips.

When to use

Use this pattern when:

  • Users need to narrow down large datasets
  • Multiple filters can be applied simultaneously
  • Filters should be visible and easily removable
  • You want to provide real-time filtering feedback

Considerations

  • Validate filter input to prevent empty or duplicate filters
  • Show applied filters as removable chips for visibility
  • Provide a “Clear all” option when multiple filters are applied
  • Display a “No results found” message when filters return empty results
  • Use case-insensitive matching for better user experience
View old example docs