A query builder has many separate controls (a status checkbox, a date picker, a search box) that all write to one filter list. If each control edits the array itself, you get duplicates, lost filters, and .filter() calls spread across every component.

Instead, one hook owns the list. Controls describe what they want, and every write goes through dedupe.

type Filter = { field: string; op: string; value: unknown };

const same = (a: Filter, b: Filter) =>
  a.field === b.field && a.op === b.op && a.value === b.value;

const dedupe = (fs: Filter[]) => fs.filter((f, i) => fs.findIndex((o) => same(o, f)) === i);

const matches = (f: Filter, c: Partial<Filter>) =>
  Object.entries(c).every(([k, v]) => f[k as keyof Filter] === v);

export function useFilters() {
  const [filters, set] = useState<Filter[]>([]);
  const write = (fn: (prev: Filter[]) => Filter[]) => set((prev) => dedupe(fn(prev)));

  return {
    filters,
    add:    (f: Filter)            => write((p) => [...p, f]),
    remove: (c: Partial<Filter>)   => write((p) => p.filter((f) => !matches(f, c))),
    has:    (c: Partial<Filter>)   => filters.some((f) => matches(f, c)),
  };
}

Partial criteria

remove and has take any subset of a filter, so one API covers every case:

has({ field: "status", value: "active" });   // is this checkbox checked?
remove({ field: "status" });                  // clear the whole section
remove({ field: "status", value: "active" }); // uncheck one box

The filter list then feeds a config-driven filter panel.