How to Implement Live Search with Filters for 1,324 Exercises: A Complete Client-Side Solution
Build a responsive exercise explorer by pre-computing a search index, debouncing input at 250ms, and using JavaScript Sets for O(1) filter lookups—all without server requests.
The hasaneyldrm/exercises-dataset repository ships a fully client-side solution for browsing 1,324 fitness exercises with instant search and multi-criteria filtering. The implementation lives entirely in index.html, loading static data from data/exercises.json and leveraging optimized vanilla JavaScript to deliver sub-250ms query responses in the browser.
Architecture Overview
The solution employs a lightweight state machine pattern that keeps the entire dataset in memory while pre-computing searchable strings. According to the source code in index.html (lines 74-81), the application maintains three core state objects: state.exercises for the raw data, state.search for the query string, and state.filters containing three Set objects for Category, Equipment, and Target Muscle selections.
Data Loading and Index Initialization
On initialization, the script loads the EXERCISES constant (generated from data/exercises.json) into state.exercises. Immediately after, it constructs the filter options and pre-computes the search index:
// index.html lines 22-30
state.exercises = EXERCISES;
state.exercises.forEach(e => {
e._idx = `${e.name} ${e.category} ${e.target} ${e.equipment} ${e.muscle_group}`.toLowerCase();
});
buildFilterOptions();
applyFilters();
Pre-Computing the Searchable Index
Each exercise receives a _idx property (lines 26-28) containing a lowercase concatenation of all searchable fields. This denormalization enables the filter logic to use simple substring matching (String.prototype.includes()) rather than traversing nested object properties, reducing query complexity from O(n·m) to O(n) where n is the record count.
Building the Filter Interface
The UI generates interactive filter chips dynamically from the unique values present in the dataset, ensuring the interface always reflects the actual available options.
Generating Filter Chips
The buildFilterOptions() function extracts unique, sorted values for Category, Equipment, and Target Muscle, then renders them as clickable chips (lines 36-44). A "show more" button lazily reveals additional chips to prevent interface clutter while maintaining access to all 1,324 exercises' metadata variations.
Debounced Input Handling
The search input (lines 50-52) implements a 250ms debounce to prevent filtering during rapid keystrokes:
// index.html lines 91-96
searchEl.addEventListener('input', debounce(() => {
state.search = searchEl.value;
searchClearEl.classList.toggle('visible', state.search.length > 0);
applyFilters();
}, 250));
This timer ensures the filter logic only executes after the user pauses typing, maintaining responsive UI performance while providing the appearance of live search.
Core Search and Filter Logic
The applyFilters() function (lines 92-103) serves as the engine of the explorer, performing a single-pass filter over the exercise array.
The Filter Algorithm
The function checks active filters in sequence, returning early for any mismatch to minimize CPU cycles:
// index.html lines 92-103
function applyFilters() {
const q = state.search.toLowerCase().trim();
const { category, equipment, target } = state.filters;
state.filtered = state.exercises.filter(ex => {
if (q && !ex._idx.includes(q)) return false;
if (category.size && !category.has(ex.category)) return false;
if (equipment.size && !equipment.has(ex.equipment)) return false;
if (target.size && !target.has(ex.target)) return false;
return true;
});
state.page = 0;
renderGrid();
updateResultsBar();
updateActiveBadges();
}
Using JavaScript Sets for category, equipment, and target provides O(1) membership testing via Set.prototype.has(), critical for maintaining performance when combining multiple filter criteria across 1,324 records.
Toggle Mechanism
Clicking a chip toggles its value in the corresponding Set and updates the active class (lines 105-112). The state mutation triggers applyFilters() automatically, ensuring the UI always reflects current selections.
Rendering and Performance Optimizations
Rendering thousands of DOM nodes simultaneously causes layout thrashing and dropped frames. The implementation uses virtualization techniques to maintain 60fps performance.
Infinite Scroll Pagination
Rather than injecting 1,324 cards at once, renderGrid() (lines 111-130) renders only the first page (default 60 items). An IntersectionObserver watches a sentinel element at the grid's bottom:
// index.html lines 152-162
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && spinnerEl.classList.contains('visible')) {
appendNextPage();
}
});
observer.observe(sentinelEl);
When the sentinel enters the viewport, appendNextPage() slices the next set of results from state.filtered and appends them to the grid, creating seamless infinite scroll without scroll position jumps.
Lazy Media Loading
Exercise demonstrations use animated GIFs that load only on user interaction. Each card stores the animation URL in a data-src attribute, swapping it into src only on hover (lines 126-134). This keeps the initial page weight minimal despite the large media assets associated with the dataset.
Managing Active Filter State
Selected filters render as removable badges above the results grid (lines 124-144). Each badge displays the filter value and includes a click handler to delete that value from its respective Set. A "Clear all" button resets all three Set objects simultaneously and triggers a full re-render, providing immediate recovery from zero-result states.
Summary
- Pre-compute a search index by concatenating searchable fields into a lowercase
_idxstring for fast substring matching - Use JavaScript Sets for active filter storage to achieve O(1) membership testing during the filter loop
- Debounce input handlers at 250ms to balance perceived responsiveness with computational efficiency
- Implement infinite scroll with
IntersectionObserverto render only visible DOM nodes and prevent layout thrashing - Lazy-load media assets on hover to minimize initial payload while preserving rich content accessibility
Frequently Asked Questions
How does the search handle 1,324 exercises without server requests?
The implementation loads the complete data/exercises.json into browser memory as the EXERCISES constant. By pre-computing the _idx field (a lowercase string containing name, category, target, equipment, and muscle group), the applyFilters() function performs simple substring checks using String.prototype.includes() rather than complex queries. This client-side architecture eliminates network latency entirely, delivering instant results limited only by JavaScript execution speed.
Why are JavaScript Sets used instead of arrays for filter tracking?
Sets provide constant-time complexity (O(1)) for membership testing via the has() method. When filtering across 1,324 exercises with multiple active criteria (category, equipment, target), checking category.has(ex.category) performs significantly faster than array.includes(ex.category), which requires iterating the entire array. This optimization becomes critical when re-filtering on every keystroke during live search.
How do I modify the number of exercises shown per page?
Adjust the pagination logic in renderGrid() and appendNextPage() (lines 111-130). The default implementation slices 60 items per page from the state.filtered array. Modify the slice arguments (e.g., state.filtered.slice(0, 24)) to match your performance targets and card dimensions. Smaller pages reduce initial render time, while larger pages reduce scroll interruption frequency.
Can I extend this implementation to filter by additional exercise properties?
Yes. First, ensure the new property exists in data/exercises.json and is documented in data/exercises.schema.json. Then extend the state.filters object (line 78) with a new Set for your property. Update buildFilterOptions() to extract unique values for the new dimension, and add a corresponding check in applyFilters() using newFilterSet.has(ex.new_property). Finally, render chips for the new filter in the UI section (lines 36-44).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →