# How to Implement Live Search with Filters for 1,324 Exercises: A Complete Client-Side Solution

> Implement live search with filters for 1324 exercises using a client-side solution. Learn to build a responsive explorer with pre-computed search index and O(1) filter lookups efficiently.

- Repository: [Hasan Emir Yıldırım/exercises-dataset](https://github.com/hasaneyldrm/exercises-dataset)
- Tags: how-to-guide
- Published: 2026-07-30

---

**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`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/index.html), loading static data from [`data/exercises.json`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/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`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/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`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/data/exercises.json)) into `state.exercises`. Immediately after, it constructs the filter options and pre-computes the search index:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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:

```javascript
// 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 `_idx` string 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 `IntersectionObserver` to 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`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/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`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/data/exercises.json) and is documented in [`data/exercises.schema.json`](https://github.com/hasaneyldrm/exercises-dataset/blob/main/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).