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
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
- Single Responsibility - Filter logic is centralized. UI components only interact via
add()
,remove()
, orupdate()
. I wanted to avoid things likeArray.filter()
calls scattered in the consumers. - Encapsulation - Consumers don’t know how filters are stored. They use a clean, developer-friendly API.
- 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.