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.
by jtt
Solo Developer & Writer

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:
CopyEmailButton: A straightforward button that copies my email address to the clipboard and briefly changes state to say “Copied!”.ToastProvider: A system to display temporary, non-blocking notification messages across the site.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.
Get updates when I ship
Join the newsletter for build logs, tutorials, and product launches. No spam, unsubscribe anytime.
Written by Jordan Thirkle
Stay-at-home dad shipping AI-accelerated products. Posts come from real builds, not theory.
Continue Reading
Agentic AI Workflows: Architecting Resilient SDLC Pipelines
How to integrate agentic AI into your SDLC for resilient, self-healing CI/CD pipelines and automated code remediation in modern web architecture.
Astro 6 View Transitions: High-Performance DOM Caching
Master Astro 6 view transitions. Learn how to architect zero-layout-shift DOM caching and unblock the event loop for highly performant, minimalist web apps.
Edge-Native Micro-SaaS with Astro 6 and Preact
A deep dive into building ultra-fast, edge-native micro-SaaS applications using Astro 6, Preact, and Cloudflare Workers. Zero-BS engineering for solo devs.