Skip to content
← All posts
Web Development6 min read

Converting React Islands to Vanilla JS in Astro 6

How migrating core UI components from React to vanilla JS shaved 29KB off our Astro 6 payload, improving TTI and interaction responsiveness.

bj

by jtt

Solo Developer & Writer

Converting React Islands to Vanilla JS in Astro 6
In this post

TL;DR: React is powerful, but overusing it for simple UI interactions introduces unnecessary hydration overhead. By rewriting targeted, high-use components—like Copy buttons, Toast providers, and Reading Progress bars—into pure Vanilla JS within Astro 6, we shed 29KB of JavaScript payload and measurably improved Time to Interactive (TTI).

The Trap of Defaulting to React

When building an Astro project, the “Island Architecture” is the killer feature. You ship pure HTML for static content and selectively hydrate React (or Vue/Svelte) components where interactivity is necessary. It’s an elegant solution to the SPA bloat problem.

However, there’s a subtle trap developers often fall into: defaulting to a framework for everything interactive.

Need a simple button that copies text to the clipboard? Just build a React component. Need a toast notification? React context provider. A reading progress bar? useEffect hook linked to scroll events.

This mindset erodes the fundamental value proposition of Astro. Every React component you add requires the framework runtime to load, parse, and execute on the client. Even small components carry this overhead, delaying hydration for the truly critical interactive pieces of your application.

Identifying the Bottlenecks

In the recent optimization pass of this portfolio site, I conducted a brutal audit of the client-side JavaScript. The goal was simple: identify React components that didn’t actually need React.

I found three prime candidates:

  1. CopyEmail Button: A straightforward button that copies my email address to the clipboard and briefly changes state to say “Copied!”.
  2. ToastProvider: A system to display temporary, non-blocking notification messages across the site.
  3. ReadingProgress: A horizontal bar at the top of blog posts that fills up as the user scrolls down the page.

None of these require complex state management, virtual DOM reconciliation, or reactive data binding. They are essentially pure DOM manipulation.

Architecture Overview

graph TD
    A[User Request] --> B{Astro Server/Static HTML}
    B --> C[Core Content & Layout]
    B --> D[Vanilla JS Elements]
    B --> E[React Islands]

    C --> F[Browser Paints Fast]
    D --> G[Instant Interactivity No Hydration]
    E --> H[Hydrates Later via React]

    G --> I[Copy Button, Progress Bar]
    H --> J[Complex State, Forms]

    style G stroke:#22c55e,stroke-width:2px
    style H stroke:#eab308,stroke-width:2px

The Rewrite Strategy

The process of converting these to Vanilla JS within the Astro ecosystem involves moving the logic out of .tsx files and directly into <script> tags within standard .astro components.

1. The Copy Button

Before (React):

import { useState } from 'react';

export default function CopyEmail({ email }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    navigator.clipboard.writeText(email);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <button onClick={handleCopy} className="btn">
      {copied ? 'Copied!' : 'Copy Email'}
    </button>
  );
}

After (Astro + Vanilla JS):

---
const { email } = Astro.props;
---

<button id="copy-email-btn" data-email={email} class="btn">
  <span class="default-text">Copy Email</span>
  <span class="copied-text hidden">Copied!</span>
</button>

<script>
  const btn = document.getElementById('copy-email-btn');
  const defaultText = btn?.querySelector('.default-text');
  const copiedText = btn?.querySelector('.copied-text');

  btn?.addEventListener('click', () => {
    const email = btn.dataset.email;
    if (email) {
      navigator.clipboard.writeText(email);
      defaultText?.classList.add('hidden');
      copiedText?.classList.remove('hidden');

      setTimeout(() => {
        defaultText?.classList.remove('hidden');
        copiedText?.classList.add('hidden');
      }, 2000);
    }
  });
</script>

This approach leverages the data-* attributes to pass server-side props into the client-side script cleanly. More importantly, it removes the React runtime dependency entirely for this feature.

2. The Reading Progress Bar

The reading progress bar is a classic example of unnecessary React state. Hooking into the scroll event to update a React state variable forces a re-render on every scroll tick. This is incredibly inefficient.

Vanilla JS Approach:

---
// ReadingProgress.astro
---
<div id="progress-bar" class="fixed top-0 left-0 h-1 bg-accent-primary w-0 z-50 transition-all duration-100 origin-left"></div>

<script>
  // Cache the DOM query outside the event listener
  const progressBar = document.getElementById('progress-bar');

  const updateProgress = () => {
    if (!progressBar) return;

    // Calculate scroll percentage
    const scrollPx = document.documentElement.scrollTop;
    const winHeightPx = document.documentElement.scrollHeight - document.documentElement.clientHeight;

    // Avoid division by zero on very short pages
    if (winHeightPx === 0) return;

    const scrolled = `${(scrollPx / winHeightPx) * 100}%`;
    progressBar.style.width = scrolled;
  };

  // Use a passive listener for better scroll performance
  window.addEventListener('scroll', updateProgress, { passive: true });
</script>

By manipulating the DOM node’s style.width directly in a passive scroll listener, we bypass the virtual DOM entirely. It’s significantly faster and completely avoids React hydration.

Handling View Transitions

A critical detail when working with Vanilla JS in Astro is handling view transitions. If you’re using Astro’s built-in <ViewTransitions />, standard <script> tags are only executed on the initial page load. When the user navigates to a new page, the DOM is updated, but the scripts won’t re-run automatically.

To fix this, you must hook into the astro:page-load event.

<script>
  function initializeReadingProgress() {
    const progressBar = document.getElementById('progress-bar');
    // ... logic ...
    window.addEventListener('scroll', updateProgress, { passive: true });
  }

  // Run on initial load and after every view transition
  document.addEventListener('astro:page-load', initializeReadingProgress);
</script>

The Results: 29KB Lighter

After migrating the CopyEmail, ToastProvider, and ReadingProgress components, the results were definitive.

The total JavaScript payload sent to the client dropped by exactly 29KB (gzipped). While 29KB might not sound massive on a broadband connection, on a constrained 3G mobile network, that’s a noticeable reduction in parse and execution time.

More importantly, it reduced the Total Blocking Time (TBT) during the critical hydration phase. By removing non-essential React components, the browser can prioritize hydrating the genuinely interactive parts of the application (like complex forms or real-time data visualizers) faster.

The Takeaway: Pragmatism over Framework Loyalty

This exercise reinforced a core architectural principle: Use the right tool for the job.

React is incredible for managing complex, interdependent state. It is overkill for toggling a CSS class or updating a width percentage on scroll.

Astro provides the perfect environment to mix these paradigms. You can have a heavy, stateful React Island living right next to a lightning-fast, zero-JS static element and a pure Vanilla JS interactive component.

The path to building brutally fast, resilient web systems isn’t about abandoning frameworks entirely; it’s about being deeply critical of when and where you deploy them. Start with zero JS, add Vanilla JS when simple DOM manipulation is needed, and reach for a framework only when state complexity demands it.

Topics

Get updates when I ship

Join the newsletter for build logs, tutorials, and product launches. No spam, unsubscribe anytime.

Subscribe
Share

Written by Jordan Thirkle

Stay-at-home dad shipping AI-accelerated products. Posts come from real builds, not theory.

0