← Back to articles
8 min read

Zero-JS Filtering: High Performance DOM in Astro 6

How to implement client-side category filtering in Astro without React state or hydration. A deep dive into zero-BS DOM manipulation for maximum performance.

Zero-JS Filtering: High Performance DOM in Astro 6

TL;DR: You do not need React to build interactive UI filters. By dropping complex client-side state and using pure, vanilla JS to manipulate the DOM, you can maintain a zero-JS baseline in Astro 6 while delivering instantaneous category filtering.


The modern web is suffering from a hydration obsession. We’ve been trained to reach for React state (useState), Next.js routers, or Svelte stores the moment a user needs to filter a list. This is over-engineering at its finest. It forces your users to download, parse, and execute megabytes of JavaScript just to hide a few <div> elements.

In my recent architecture pass for this Creator Hub, I needed to implement category filtering for the project grids and dev logs. The instinct was to use a React Island and Nanostores. I caught myself. That is not zero-BS engineering. We can do better.

We are using Astro 6. The goal is a zero-JS baseline. If an interaction does not fundamentally require persistent complex state, it should not hydrate. We need to eliminate the React tax for basic DOM interactions.

Here is how I implemented high-performance, instantaneous DOM filtering using zero framework overhead.

The Architecture of Vanilla Filtering

The strategy is brutally simple, mirroring the Unix philosophy of doing one thing well:

  1. Server-Side Render (SSR) everything: Render all elements unconditionally on the server.
  2. Data Attributes for State: Tag each element with a data-category attribute.
  3. Vanilla JS Toggling: Write a pure vanilla JavaScript function to toggle a CSS hidden class based on the selected category.

No Virtual DOM. No reconciliation loop. No React Context. Just raw DOM node manipulation. This approach represents a massive shift back to the fundamentals of the web, proving that complex architectures are often a solution looking for a problem.

The Implementation Deep Dive

Let’s look at the exact code required to implement this. First, we define a shared utility function. This keeps the logic centralized, testable, and reusable across different pages. I recently refactored this into src/utils/dom.ts:

/**
 * Shared DOM filtering logic for category-based elements.
 * Toggles the 'hidden' class based on the element's data-category attribute.
 */
export const applyCategoryFilter = (
  elements: NodeListOf<Element> | Element[],
  filter: string | null
) => {
  elements.forEach((el) => {
    const element = el as HTMLElement;
    const category = element.dataset.category;
    if (!filter || category === filter) {
      element.classList.remove('hidden');
    } else {
      element.classList.add('hidden');
    }
  });
};

This function does exactly one thing: it iterates over a NodeList or an Array of elements, checks their dataset.category, and adds or removes the hidden class. The hidden class is a standard Tailwind CSS utility mapping to display: none. This is a constant-time O(N) operation where N is the number of elements on the page, executed natively by the browser’s optimized CSS engine.

Wiring it up in Astro

The magic of Astro is how it handles client-side scripts. In your .astro component, you simply add a standard <script> tag. Astro will automatically bundle, transpile, and minify this script, but critically, it executes natively on the client without hydrating a framework runtime.

---
// Server-side rendering context
const items = [
  { id: 1, title: 'Project Alpha', category: 'design' },
  { id: 2, title: 'Project Beta', category: 'systems' },
  { id: 3, title: 'Project Gamma', category: 'ai' },
  { id: 4, title: 'Project Delta', category: 'systems' }
];
---

<!-- The Interactive Filter Buttons -->
<nav class="flex gap-4 mb-8">
  <button data-filter="all" class="filter-btn px-4 py-2 bg-zinc-800 text-white rounded">All</button>
  <button data-filter="design" class="filter-btn px-4 py-2 bg-zinc-800 text-white rounded">Design</button>
  <button data-filter="systems" class="filter-btn px-4 py-2 bg-zinc-800 text-white rounded">Systems</button>
  <button data-filter="ai" class="filter-btn px-4 py-2 bg-zinc-800 text-white rounded">AI</button>
</nav>

<!-- The Filterable Grid Items -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
  {items.map(item => (
    <article data-category={item.category} class="filterable-item p-6 border border-zinc-700 rounded-lg">
      <h3 class="text-xl font-bold">{item.title}</h3>
      <span class="text-zinc-400 uppercase text-xs tracking-wider">{item.category}</span>
    </article>
  ))}
</div>

<script>
  // Import our centralized logic
  import { applyCategoryFilter } from '@/utils/dom.ts';

  // Grab the necessary DOM nodes once
  const buttons = document.querySelectorAll('.filter-btn');
  const items = document.querySelectorAll('.filterable-item');

  // Attach event listeners
  buttons.forEach(btn => {
    btn.addEventListener('click', (e) => {
      // Determine the active filter from the button's dataset
      const filter = (e.target as HTMLElement).dataset.filter;
      const activeFilter = filter === 'all' ? null : filter;

      // Execute the DOM manipulation
      applyCategoryFilter(items, activeFilter);

      // Optional: Update active button state here
      buttons.forEach(b => b.classList.remove('bg-blue-600'));
      (e.target as HTMLElement).classList.add('bg-blue-600');
    });
  });
</script>

In this example, everything inside the --- code fence runs exclusively on the server at build time. The HTML is generated statically. The <script> block is shipped to the client, amounting to just a few hundred bytes of JavaScript. When a user clicks a button, the filtering happens instantly without any loading states or VDOM diffing.

Why This Matters for Performance

This approach might seem overly simplistic, but that is precisely the point. The lack of complexity is a feature, not a bug.

  • Zero Framework Cost: We aren’t loading React, Preact, Vue, or any state management library. The bundle size overhead for this feature is effectively zero. In a world where median mobile page sizes exceed 2MB, saving hundreds of kilobytes on a framework runtime is a massive win.
  • Instant Execution: Direct DOM manipulation using classList is incredibly fast. The browser doesn’t have to diff a virtual DOM, calculate a minimum set of changes, and then apply them. It just executes a native C++ bound method and repaints the layout.
  • Graceful Degradation and Progressive Enhancement: If JavaScript fails to load (perhaps due to a spotty connection or a corporate firewall), or if the user has disabled it entirely, all items remain visible. The site doesn’t break; it just degrades gracefully. The core content remains accessible.
  • Simplified Mental Model: By removing the abstraction of a VDOM, we reduce cognitive load. The code does exactly what it says it does: it finds elements and hides them. There are no lifecycle hooks, dependency arrays, or stale closures to worry about.

The Architect’s Perspective

When building systems, ask yourself if you are solving a complex problem or just throwing a complex tool at a simple one. We’ve been conditioned to think that modern web development requires modern, heavy tools. This is a fallacy.

The best architectures are minimalist architectures. They rely on the platform (the browser) as much as possible and only reach for user-land abstractions when absolutely necessary. By adopting a zero-JS baseline and using tools like Astro to manage our islands of interactivity, we can build sites that are fast by default and interactive where needed.

Ship less JavaScript. Embrace the platform. Your users, your Lighthouse scores, and your future self will thank you.

If you are interested in exploring more about Astro’s core concepts, check out the official Astro docs.

Discussions

Be the first to share your thoughts or ask a question.

120