I recently had to design a query builder system and deciding to share a bit of my approach and thoughts. The feature needed to generate a SQL-like output where the UI is composed of isolated controls—a status dropdown here, a date picker there. Each one manages its own logic. But together, they need to generate a consistent backend query like:

SELECT * FROM users
WHERE status = 'active'
  AND created_at > DATE('2024-01-01')

Up front, I knew I wanted to design a system with the following behaviors:

  • Encapsulated API — Consumers shouldn’t mutate raw state, and raw state should not be exposed
  • Deduplication — Only one filter per (field, operator, value) triplet
  • Predictable behavior — No silent overwrites
  • Composable — Can be scoped per tab, page, or context
  • Declarative — Consumers describe intent, not mechanics

JupiterOne Data Explorer

The Hook

interface Filter {
  field: string;
  operator: string;
  value: string | number | boolean | Date;
}

// Ensure no duplicate filters exist, put in the module instead of the hook
// since it's a helper.
const dedupe = (filters: Filter[]) => {
  return filters.filter(
    (f, i, arr) =>
      i === arr.findIndex(
        o =>
          o.field === f.field &&
          o.operator === f.operator &&
          o.value === f.value
      )
  );
};

function useFilterManager() {
  const [filters, setFilters] = useState<Filter[]>([]);

  const updateFiltersSafely = (updater: (prev: Filter[]) => Filter[]) => {
    setFilters(prev => dedupe(updater(prev)));
  };

  const add = (filter: Filter) => {
    updateFiltersSafely(prev => [...prev, filter]);
  };

  const remove = (criteria: Partial<Filter>) => {
    updateFiltersSafely(prev =>
      prev.filter(f =>
        !Object.entries(criteria).every(
          ([key, val]) => f[key as keyof Filter] === val
        )
      )
    );
  };

  const update = (fn: (prev: Filter[]) => Filter[]) => {
    updateFiltersSafely(fn);
  };

  const has = (criteria: Partial<Filter>) => {
    return filters.some(f =>
      Object.entries(criteria).every(
        ([key, val]) => f[key as keyof Filter] === val
      )
    );
  };

  const toSQLQuery = () => {
    if (filters.length === 0) return '';
    const clauses = filters.map(
      ({ field, operator, value }) => `${field} ${operator} '${value}'`
    );
    return `SELECT * FROM users WHERE ${clauses.join(' AND ')}`;
  };

  return { add, remove, update, has, toSQLQuery, filters };
}

Criteria

After I began implementing this hook, I noticed the need to have more fine-grained control over the filter state, so I added the criteria args to each method. I wanted a consistent interface where the caller could either include or omit certain fields. This came in helpful to avoid a filter section from re-fetching data as the user drilled-down (since we can omit certain fields). The has method similarly lets me determine the state of an input element.

Design Principles

  1. Single Responsibility - Filter logic is centralized. UI components only interact via add(), remove(), or update(). I wanted to avoid things like Array.filter() calls scattered in the consumers.
  2. Encapsulation - Consumers don’t know how filters are stored. They use a clean, developer-friendly API.
  3. Inversion of Control - Instead of each filter component managing its own quirks, the hook owns the filter state and decides what’s valid.

Final Thoughts

I didn’t need a full-blown query engine, so avoided building a full AST parser and query builder. I just wanted to stop wasting time untangling bugs from duplicate filters and shared state leaks.

I’ll try and post more soon about other cool features I put in here.