A glowing React atom dissolving into particles that reassemble into a clean code panel, illustrating a React island rebuilt as plain Astro markup with no client-side JavaScript.

From React Islands to Zero-JS Astro: Reproducing react-hook-form Without React

A first-person migration log: how I removed every React island from the first screen of my Astro site, reproduced react-hook-form behavior in a vanilla TypeScript controller, and cut 162 npm packages.

#astro #react #cloudflare workers #performance #migration #typescript #islands architecture #core web vitals

The first screen of my Astro site had two interactive pieces: a top navigation menu and a newsletter form. To run those two widgets, every visitor downloaded react, react-dom, react-hook-form, radix-ui, cmdk, sonner, and a stack of icon components. Astro ships zero JS by default, and I had quietly turned the most-viewed screen on the site into a React app.

Why I removed React islands from the first screen of an Astro site

I rebuilt those screens island-free in a single day, running the whole migration with Claude Code while I was still learning the tool. The end state: 162 npm packages gone, zero React in the client bundle, and a newsletter form that submits even with JavaScript disabled.

Nothing about the result needed React. It only needed someone to reproduce the three things React was doing for me.

The trigger was not a red Lighthouse score. The score was already high. The problem was that it would not sit still.

Re-run PageSpeed on the same URL and Performance swung from a green 100 down into the low 80s between test runs. It shifted again deploy to deploy as small things changed on the page. A flapping score on the first screen means the main thread has work it does not need, and that work was island hydration.

TL;DR

  • Two interactive widgets on the first screen pulled in react, react-hook-form, radix-ui, cmdk, and sonner: a hydration tax on the most-viewed page.
  • A custom client:interaction directive deferred hydration until intent, which helped, but the cleaner fix was to stop shipping the island at all.
  • I swapped shadcn for Starwind UI (pure .astro markup plus vanilla TypeScript) and reproduced react-hook-form’s behavior in a generic enhanceForm controller.
  • Result: 162 npm packages removed, zero React in the client bundle, and a no-JS form fallback I never had before.
  • Astro islands are still the right tool for a genuinely interactive widget. Keep them off the first screen.

What three islands on the first screen actually cost

The first screen mounted three React islands. The desktop nav (TopNavDesctop.tsx) was a Radix NavigationMenu with Phosphor icons. The newsletter (NewsletterFlow.tsx) was a multi-step form built on react-hook-form, @hookform/resolvers, a shadcn field, and a cmdk-based multi-select, the same flow whose edge i18n I walked through in the edge-native i18n series. The toaster (ToasterWrapper.tsx) wrapped sonner.

Three small widgets, but look at what they pulled into the bundle: react, react-dom, react-hook-form, @hookform/resolvers, radix-ui, cmdk, sonner, class-variance-authority, and @phosphor-icons/react. Plus the @astrojs/react integration to glue them in.

Every one of those islands hydrates on the most-viewed page on the site. Hydration is main-thread work: download the chunk, parse it, run React, attach the handlers. On the first screen that work lands right where Total Blocking Time is measured, which is why the score would not hold still.

First fix: a custom client:interaction directive that hydrates on intent

My first instinct was not to delete the islands but to make them cheaper. Astro lets you register your own client directive with addClientDirective, so I wrote one called client:interaction. It holds hydration until the user shows intent.

The directive listens for the first mouseenter, touchstart, or focusin on the island root, then loads and hydrates once:

// src/directives/interaction.ts
export default (load, opts, el) => {
  const hydrate = async () => {
    el.removeEventListener('mouseenter', hydrate)
    el.removeEventListener('touchstart', hydrate)
    el.removeEventListener('focusin', hydrate)
    const hydrateComponent = await load()
    await hydrateComponent()
  }
  el.addEventListener('mouseenter', hydrate, { once: true })
  el.addEventListener('touchstart', hydrate, { once: true })
  el.addEventListener('focusin', hydrate, { once: true })
}

The nav and the newsletter both moved from client:load to client:interaction. Nothing on the first screen hydrates until you reach for it.

The toaster got an even lazier directive. A toast only happens after some action, so there is no reason to load sonner on page load at all. client:on-toast waits for a custom trigger-toaster window event before it hydrates:

// src/directives/on-toast.ts
export default (load, opts, el) => {
  const hydrate = async () => {
    window.removeEventListener('trigger-toaster', hydrate)
    const hydrateComponent = await load()
    await hydrateComponent()
  }
  window.addEventListener('trigger-toaster', hydrate, { once: true })
}

Both directives register in astro.config.mjs through the astro:config:setup hook:

{
  name: 'client-interaction-directive',
  hooks: {
    'astro:config:setup': ({ addClientDirective }) => {
      addClientDirective({ name: 'interaction', entrypoint: './src/directives/interaction.ts' })
    },
  },
}

The toaster needed one more piece so calling code never has to know whether sonner is loaded yet. A thin wrapper dispatches trigger-toaster to wake the island, waits for it to announce toaster-mounted, then forwards the real call:

// src/utils/shared/toast-wrapper.ts (abridged)
const wakeUpToaster = () => window.dispatchEvent(new Event('trigger-toaster'))

export const toast = {
  success: (message, options) => {
    if (isMounted) return originalToast.success(message, options)
    wakeUpToaster()
    window.addEventListener('toaster-mounted', () => originalToast.success(message, options), { once: true })
  },
  // error: same shape
}

I had promised this exact write-up. At the end of the i18n series, part 2 I left a note that a future deep-dive would cover delaying the toast library with a custom client directive. This is that deep-dive. It just arrives with a twist I did not see coming when I wrote the promise.

Why deferring hydration was fixing the wrong layer

The directives worked. Hydration moved off the initial load, Total Blocking Time dropped, and the score calmed down. For a while that felt like the answer.

Then the cost of the cleverness showed up. client:on-toast needs a two-event handshake (trigger-toaster out, toaster-mounted back), an isMounted flag, and a queue-until-ready step for any toast fired before the island mounts. The interaction directive needs its own double-hydration guards. That is a lot of machinery to keep alive so a few inputs can validate themselves.

Deferring also does not remove the bytes. The React runtime, react-hook-form, Radix, and sonner still download and execute, only later. A user who goes straight for the form pays the full hydration cost mid-interaction, at the worst possible moment.

Focus muddies the win too. focusin counts as intent, so a keyboard user hydrates the nav immediately, which is most of what I was trying to defer.

Under all of it sat the question I had been avoiding. The only reason the newsletter was a React island was react-hook-form, and I had kept it on purpose, for the UX it gives during form interaction: validation as you type, focus management, dirty tracking, clean server-error mapping.

That UX was worth keeping. What I started to doubt was whether it actually needed React. So I stopped tuning the island and went to find out.

Picking a shadcn replacement for Astro: Starwind UI vs Basecoat

Every island was a shadcn component, so removing React meant replacing shadcn with something Astro-native. I narrowed it to two: Starwind UI and Basecoat.

The split is architectural, not about how many components each ships. Starwind UI gives you real .astro components with vanilla TypeScript for interactivity, on Tailwind v4, copied into your project through a shadcn-style CLI. Basecoat is a CSS layer (btn, card, select classes) with a little vanilla JS, and you write the markup yourself.

Each has a catch. Starwind’s .astro components cannot live inside a React island, which stops mattering once the islands are gone. Basecoat’s classes work in both .astro and .tsx and map onto existing shadcn tokens, which only helps if you are keeping React.

The real cost here was never the nav or the static pieces. It was the React-bound logic: a multi-select built on cmdk and a Radix popover, and forms sitting on react-hook-form. Neither library ships a multi-select, so that component was mine to rebuild whichever way I went.

I picked Starwind UI. It is the one mature Astro-native answer to shadcn, it gives a repeatable pattern for the components it lacks (.astro plus a typed inline script, rather than only styles), and its own toast let me drop sonner outright. Basecoat only wins if you intend to keep React islands and want one design language across JSX and Astro, and I was deleting the islands.

I ruled out the rest fast. Basis UI brings the shadcn look to Astro but rides on Alpine.js, and I had no reason to add Alpine. Fulldev UI looks abandoned. For Astro today, Starwind is the strongest base.

The replacement pattern: .astro markup plus a typed inline script

Starwind components share one shape, and the ones I had to build myself copy it. Markup lives in a .astro file. Interactivity lives in a typed inline <script> that defines a small handler class, keys live instances by element in a WeakMap, and re-runs on astro:after-swap so the component survives Astro view transitions.

class MultiSelectHandler {
  /* open/close, keyboard nav, selection state */
}

const instances = new WeakMap<HTMLElement, MultiSelectHandler>()
function initMultiSelects() {
  document.querySelectorAll<HTMLElement>('[data-multi-select]').forEach((el) => {
    if (!instances.has(el)) instances.set(el, new MultiSelectHandler(el))
  })
}
document.addEventListener('astro:after-swap', initMultiSelects)

That is the whole trick that makes islands unnecessary for this kind of UI. The HTML renders on the server, the script attaches behavior to existing DOM, and no framework hydrates. No React, no virtual DOM, no per-component runtime.

Icons moved the same way. shadcn used @phosphor-icons/react, a React component per glyph. Astro imports an SVG as a component directly, so the icon renders to inline HTML at build time with zero client JS:

import MenuIcon from '@tabler/icons/outline/menu-2.svg'
<MenuIcon class="size-5" stroke-width="1" />

One design note, since it surprised people reading the diff: every component has square corners on purpose. The project sets --radius: 0 with a universal border-radius override, so new Starwind components inherit it. That is a deliberate look, not a migration regression.

Reproducing react-hook-form in vanilla TypeScript

The forms were the hard part. react-hook-form does real work: validate on blur, re-validate as you type, run a Zod schema, focus the first invalid field, track dirty state, disable submit until something changes, and map server field errors back onto inputs. I did not want to lose any of that, and I wanted to do it without React.

So I wrote one generic controller, enhanceForm(form, schema, action, options). It takes a plain <form>, a Zod schema, and an Astro Action, and knows nothing about any specific form. Everything form-specific lives in onSuccess and onActionError callbacks.

It reproduces the react-hook-form behaviors one to one:

  • Client safeParse on submit blocks the request when the form is invalid.
  • focusout validates the field that just lost focus; input re-validates only fields the user has already touched.
  • isInputError(error).fields from the action maps straight into the inline error slots, the way react-hook-form surfaces server errors.
  • Pending state sets data-pending and disables submit, the first invalid field gets focus, and submit stays disabled until a value changes.

Errors render into declarative slots the markup owns. Each field has a [data-error-for="<name>"] element; the controller writes localized messages there and toggles aria-invalid on the input. The lookup is a pure function, resolveFieldMessages, lifted out of the old React FieldErrorLocalized so the client controller and the server render share one source of truth. The message catalog is the same edge-served i18n the rest of the site runs, which I covered in the Astro i18n guide.

Here is the markup side, trimmed to what matters. The form posts natively to the action, and each field owns an error slot the controller writes into:

<form method="POST" action={actions.newsLetter.subscribe} data-newsletter-form data-config={JSON.stringify(clientConfig)} novalidate>
  <input type="hidden" name="source" value={source} />
  <input type="hidden" name="locale" value={locale} />

  <div role="group" data-slot="field">
    <Label for="nl-contact" class="sr-only">{newsletter.step.subscribe.title}</Label>
    <Input type="text" id="nl-contact" name="nl_contact" placeholder="you@domain.com" required />
    <div data-error-for="nl_contact" hidden></div>
  </div>

  <button type="submit" class="primary-button">
    <span data-pending-hide>{newsletter.buttons.joinEarlyBirds}</span>
    <span data-pending-show><SpinnerIcon class="size-4 animate-spin" /> {messages.common.PENDING}...</span>
  </button>
</form>

Input and Label are Starwind components. The pending spinner is a pure CSS swap on the data-pending attribute the controller toggles, so no script renders the button state. The wiring is where the form-specific behavior lives:

enhanceForm(form, ClientNewsletterSchema, actions.newsLetter.subscribe, {
  tErrors: config.tErrors,
  transform: (values) => ({ ...values, locale }),
  onSuccess: (data) => {
    zaraz?.track('newsletter_subscribe', { source })
    subscriberIdInput.value = String(data.subscriberId) // hand the id to step 2
    formSection.setAttribute('hidden', '')
    segmentSection.removeAttribute('hidden') // reveal the segment form
    toast.success(config.successMessage)
  },
  onActionError: (error, helpers) => {
    if (error.code === 'BAD_REQUEST' && isErrorMessageCode(error.message)) {
      helpers.setErrors({ nl_contact: error.message }) // e.g. EMAIL_ALREADY_EXISTS, inline
    } else {
      toast.error(config.serverErrorMessage) // anything unexpected, toast
    }
  },
})

The split is the one react-hook-form pushes you toward anyway: schema validation is generic, business reactions are local. The tErrors map, the locale, and the server-side no-JS messages all come from the same edge i18n engine the rest of the site runs on, my astro-edgekits-core Zero-JS i18n layer, so the form speaks the same localized error codes on the client, on the server, and on the no-JS path.

One caveat about this file. Newsletter.astro is still a first pass: it holds the subscribe markup, the segment reveal, the no-JS branch, and the client script in one component, which is more than a single file should own. Splitting it into a subscribe form, a segment wrapper, and a shared script is a small refactor I have queued, not a blocker, so it ships as-is for now.

Rebuild the multi-select as a framework-free component

The interest picker was the one component neither library gave me. The old one was cmdk plus a Radix popover plus React context, a real island. I rebuilt it as a single .astro file on the pattern above: a trigger, a listbox, selected values as pills with a “+N” overflow, check marks, and click-to-remove.

Two decisions made it behave like a real form control. Accessibility runs through aria-activedescendant instead of moving DOM focus. Focus stays on the trigger while arrow keys move a highlighted option, so the field never blurs mid-selection.

That detail is not cosmetic. If list items took focus, leaving the open menu would blur the trigger and make the form controller fire a “required” error the moment the menu opened. Keeping focus on the trigger stops the field from blurring mid-selection, so the controller does not flag it as invalid before anything is picked.

Selection mirrors into hidden inputs: a name="interests[]" element per chosen value. FormData collapses the [] suffix into an array, which is exactly what enhanceForm reads and what a native no-JS POST sends. The visible trigger carries name="interests" as a type="button", so it never enters FormData, only so the controller can paint aria-invalid on it.

The markup is a trigger, a listbox, and an empty span the script fills with hidden inputs:

<div class="starwind-multiselect relative" data-slot="multiselect" data-name={name}>
  <button type="button" name={name} data-ms-trigger role="combobox" aria-haspopup="listbox" aria-expanded="false">
    <span data-ms-value><span data-ms-placeholder>{placeholder}</span></span>
    <ChevronDown class="size-4 opacity-50" />
  </button>

  <div data-ms-content role="listbox" aria-multiselectable="true" hidden>
    {
      options.map((o) => (
        <div role="option" data-value={o.value} data-ms-item aria-selected="false" tabindex="-1">
          <Check data-ms-check class="size-4 opacity-0" />
          <span>{o.label}</span>
        </div>
      ))
    }
  </div>

  <span data-ms-inputs hidden></span>
</div>

A handler class keys off those data attributes. Toggling a value flips aria-selected, rebuilds the hidden inputs, and fires an input event so enhanceForm re-validates and updates dirty state:

private toggleValue(value: string) {
  this.selected.has(value) ? this.selected.delete(value) : this.selected.add(value)
  this.items[this.order.indexOf(value)]?.setAttribute('aria-selected', String(this.selected.has(value)))
  this.render()
  this.trigger.dispatchEvent(new Event('input', { bubbles: true })) // notify enhanceForm
}

private render() {
  const chosen = this.order.filter((v) => this.selected.has(v))
  // one hidden name="interests[]" per value → FormData collapses to an array
  this.inputsBox.replaceChildren(...chosen.map((v) => {
    const input = document.createElement('input')
    input.type = 'hidden'; input.name = `${this.name}[]`; input.value = v
    return input
  }))
  // ...rebuild the visible pills and the "+N" overflow badge (omitted)
}

Keyboard navigation stays on the trigger through aria-activedescendant, never moving DOM focus into the list:

private setActive(index: number) {
  this.items.forEach((item, i) => item.toggleAttribute('data-active', i === index))
  const active = this.items[index]
  active
    ? this.trigger.setAttribute('aria-activedescendant', active.id)
    : this.trigger.removeAttribute('aria-activedescendant')
}

Instances live in a WeakMap and re-initialize on astro:after-swap, the lifecycle from the pattern above.

Composing the segment step: a conditional field and a typed transform

The newsletter is two steps: subscribe, then a short segmentation form. Both run on the same enhanceForm controller. The segment form adds two things a flat form does not have: a conditional field and a reshaping transform.

The client schema mirrors the server’s, conditional rule included. superRefine reproduces react-hook-form’s watch('preferredBilling') logic, making the “other provider” field required only when billing is set to other:

const ClientSegmentationSchema = z
  .object({
    interests: z.preprocess((v) => (Array.isArray(v) ? v : v == null ? [] : [v]), z.array(z.enum(INTEREST_VALUES)).min(1, { message: ERROR_MESSAGE_CODES.INTERESTS_REQUIRED })),
    preferredBilling: z.enum(BILLING_PROVIDER_VALUES),
    preferredBillingOther: z.string().default(''),
    buildingNote: z.string().default(''),
  })
  .superRefine((data, ctx) => {
    if (data.preferredBilling === 'other' && !data.preferredBillingOther.trim()) {
      ctx.addIssue({ code: 'custom', path: ['preferredBillingOther'], message: ERROR_MESSAGE_CODES.BILLING_OTHER_REQUIRED })
    }
  })

Revealing the conditional field is a DOM listener on the Starwind Select’s change event, not framework state:

form.addEventListener('starwind-select:change', (event) => {
  const isOther = event.detail?.value === 'other'
  otherWrapper.hidden = !isOther
  if (!isOther) controller.setErrors({ preferredBillingOther: [] }) // clear any stale error
})

The transform option reshapes the flat values into the action’s nested input, and reads subscriberId from the hidden field the subscribe step wrote earlier:

enhanceForm(form, ClientSegmentationSchema, actions.newsLetter.segment, {
  tErrors: config.tErrors,
  disableUntilDirty: false,
  transform: (values) => ({
    subscriberId: Number(subscriberIdInput.value),
    segmentation: { ...values },
  }),
  onSuccess: () => {
    segmentSection.hidden = true
    doneSection.hidden = false
  },
  onActionError: (error, helpers) => (error.code === 'BAD_REQUEST' && isErrorMessageCode(error.message) ? helpers.setFormError(error.message) : toast.error(config.serverErrorMessage)),
})

That is the whole two-step flow with no island: subscribe writes subscriberId into a hidden input and reveals the segment form, the segment form reads it back through transform, and one generic controller drives both.

No-JS forms came free with the rewrite

The React island had no fallback. With JavaScript off, the newsletter form did nothing. The rewrite gave me a working no-JS path almost as a side effect, because Astro Actions already support native form posts.

The form carries method="POST" and action={actions.newsLetter.subscribe}. With JavaScript, enhanceForm intercepts the submit and calls the action through the client. Without it, the browser posts the form natively and the page reads the outcome with Astro.getActionResult.

The server render decides the rest. The subscribe handler sets the cookie, so on that same render isSubscribed is still false; the page consults the action result instead to show the done state. Field errors and known domain codes, like a malformed email or an already-subscribed address, render inline and server-side, in the same [data-error-for] slots the client uses.

The controller calls preventDefault() only when JavaScript is present, so the native path is never touched. One set of error slots serves both. The multi-step segmentation step stays JS-only by design: a no-JS subscribe sets the cookie and the next render skips straight to done, so there is nothing to degrade.

Sequencing a seven-phase migration without breaking main

Pulling a framework out of a live site is the kind of change that breaks halfway and strands you. I ran it as seven phases on a branch, with one rule: every phase ends green.

All work happened on a migration branch, never on main, so the old island site stayed deployable throughout. Each phase was one vertical slice (nav, then toast, then the form controller, then the newsletter, then the segmentation form) and ended with a passing build and a commit. One phase, one commit, no screen left half-migrated across commits.

A grep guard kept the backlog from drifting. A small Node script scanned the source for any import of the old shadcn components, checked against an allowlist of files still permitted to use them. The allowlist had to shrink every phase and reach empty before the teardown. It was the migration’s progress bar.

The whole run was logged in an append-only file committed next to the code, so any session could resume from the last green phase. That mattered more than usual here. I was driving the migration through Claude Code, and a written log we could both read meant a fresh session picked up exactly where the last one stopped, with no context lost.

Tearing out React: 162 packages and the config cleanup

With every consumer migrated, the teardown was mechanical. Out went radix-ui, cmdk, sonner, react-hook-form, @hookform/resolvers, class-variance-authority, and @phosphor-icons/react. Then, once a grep confirmed no client:* directive and no from 'react' import survived, react, react-dom, @astrojs/react, and the React type packages followed. npm install removed 162 packages.

The config got lighter in the same pass. Moving to Astro 6 on Cloudflare had forced a layer of React-defense config: a long optimizeDeps include list, a react-dom/server to react-dom/server.edge alias, and a React dedupe block, all to stop the dev server from desyncing React across optimize passes. I wrote up that fight in the Astro 5 to 6 migration. With React gone, all of it deleted cleanly.

What stayed is a small server-only core: the Zod and Drizzle entries the SSR build still needs, and the astro:i18n exclude that prevents a separate reload bug. The React integration, the client-directive registrations, and the jsx settings in tsconfig came out.

The check that mattered most came at the end. The production client build now contains zero React. The browser ships 59 vanilla inline-script chunks and not a byte of react-dom. The first screen, the thing this whole migration was about, hydrates nothing.

The performance picture: stable green instead of a flapping score

This is not a red-to-green story, so here is where the numbers actually stood. With the islands still in place, two PageSpeed runs on the same URL about two hours apart told the whole story. The first looked like this:

PageSpeed Insights for the React-island build, run one: 100 Performance on mobile and 99 on desktop, with Accessibility, Best Practices, and SEO at 100.

And this is the second run, with nothing changed on the site:

PageSpeed Insights for the same React-island build two hours later, run two: 97 Performance on mobile and 90 on desktop, the same page on a worse sample.

The problem was the swing, not the ceiling. Same build, nothing deployed between the two runs, and desktop Performance moved nine points, 99 down to 90, while mobile slipped from 100 to 97. Accessibility, Best Practices, and SEO stayed at 100 the whole time, and on a worse sample the score has dipped into the 80s. That is what first-screen hydration buys you: variable main-thread work the lab catches whenever a run lands badly.

Taking out the hydration takes out one of the big variables. Every PageSpeed run happens under different network conditions, so perfectly stable numbers are probably impossible by definition. But removing the framework that boots on the first screen leaves the main thread far less to block on, which should clearly help performance.

When is a React island still the right call?

Removing React from this site worked because the interactivity was shallow: a nav, a form, a toast. None of it needed component state worth a framework. That is not every case.

An island earns its hydration when the widget is genuinely stateful and lives on the page: a complex data grid, a canvas editor, a live dashboard, anything with deep interaction you would otherwise hand-roll and get wrong. Astro islands are a good feature, and client:visible or a custom client:interaction directive are the right tools for that kind of widget.

The rule I would hand my past self is narrower than “avoid islands.” Keep them off the first screen. Hydration cost is most expensive exactly where the first paint and the Core Web Vitals samples happen. A heavy island below the fold, loaded on visibility, is fine; the same island in the hero is the thing dragging your score around.

What this cost me is real. I now own code react-hook-form used to own: the validation lifecycle, the focus management, the multi-select accessibility. That is more surface to maintain and test myself. I traded a dependency I did not control for code I do, which is the trade I wanted, but it is a trade and not a free win.

The patterns in this teardown, the island-free first screen, the vanilla enhanceForm controller, the edge-native Astro and Cloudflare stack, are being proven in production right now, in front of you. The best practices that hold up go straight into my EdgeKits Starter Kits.

If that is the approach you want to build on, the Early Birds list gets the launch perks first, along with new build-in-public teardowns and blog updates. The form below is the same island-free Astro component this article is about, and it will actually subscribe you.

Leave your email to get launch discount • No spam ever

Frequently Asked Questions

How do I replace react-hook-form in Astro without shipping React?

Reproduce its behavior in a framework-free controller. On the EdgeKits site I wrote enhanceForm(form, schema, action, options), a generic vanilla TypeScript function that takes a plain HTML form, a Zod schema, and an Astro Action. It runs a client-side safeParse on submit, validates each field on focusout, re-validates touched fields on input, focuses the first invalid field, tracks dirty state to disable submit until a value changes, and maps server isInputError(error).fields back into inline error slots. Form-specific logic stays in onSuccess and onActionError callbacks, so the controller knows nothing about any single form. The result is the same validation UX react-hook-form gives, with zero React or react-hook-form in the client bundle, and the same Zod schema can validate again on the server through the Astro Action.

What does removing React islands from an Astro site actually involve?

It is a component-library swap plus a logic rewrite, not a delete. On my Astro site the islands were shadcn components, so I moved to Starwind UI, which ships .astro components with vanilla TypeScript. The interactive logic that genuinely depended on React, a react-hook-form newsletter and a cmdk-based multi-select, had to be rebuilt: forms moved to a vanilla enhanceForm controller, and the multi-select became a single .astro file with a typed inline script. Icons moved from @phosphor-icons/react to Tabler SVG imports that render to inline HTML. After every consumer was migrated, the teardown removed react, react-dom, @astrojs/react, react-hook-form, radix-ui, cmdk, sonner, and more, 162 npm packages in total, and the production client build then contained zero React.

What is the difference between Starwind UI and Basecoat for replacing shadcn in Astro?

The difference is architecture, not component count. Starwind UI gives you real .astro components with vanilla TypeScript for interactivity, on Tailwind v4, copied into your project through a shadcn-style CLI. Basecoat is a CSS layer (btn, card, select classes) plus a little vanilla JS, where you write the markup yourself. Starwind components cannot be used inside a React island, while Basecoat classes work in both .astro and .tsx and map onto existing shadcn theme tokens. That makes Basecoat the better fit only if you are keeping React islands and want one design language across JSX and Astro. If you are removing React entirely, Starwind is the stronger base, because it gives a repeatable component pattern (.astro plus a typed inline script) and its own toast, which lets you drop sonner.

When should you keep a React island in Astro instead of removing it?

Keep an island when the widget is genuinely stateful and lives on the page, like a complex data grid, a canvas editor, or a live dashboard, where hand-rolling the interaction in vanilla JS would be error-prone. Astro islands are a good feature for that, and client:visible or a custom client:interaction directive are the right tools. The rule that matters is narrower than "avoid islands": keep them off the first screen. Hydration is main-thread work, and it is most expensive exactly where first paint and the Core Web Vitals samples happen. A heavy island below the fold, loaded on visibility, is fine. The same island in the hero is what drags your Lighthouse score around. On my site the first-screen interactivity was a nav and a newsletter form, shallow enough to rebuild without a framework.

Why does a React island on the first screen make Lighthouse scores unstable?

Because hydration is variable main-thread work, and the first screen is where Total Blocking Time is measured. Each island has to download its chunk, parse it, run React, and attach handlers, and that work competes for the main thread right when the lab samples responsiveness. On the EdgeKits site, two PageSpeed runs on the same islands build about two hours apart returned 100 then 97 on mobile and 99 then 90 on desktop, with no deploy in between, and on a worse sample the mobile score has dipped into the 80s. The instability is the tell: a flapping score means the main thread has work it does not need. Removing the island removes the hydration, which removes the variable, so the score has no reason to swing.

Can an Astro form work without JavaScript after removing React?

Yes, and it comes almost for free, because Astro Actions support native form posts. The form carries method="POST" and action={actions.x}. With JavaScript, the enhanceForm controller intercepts the submit and calls the action through the client, and it calls preventDefault() only when JS is present, so the native path is untouched. Without JavaScript, the browser posts the form natively and the page reads the outcome with Astro.getActionResult. The server render then shows the success state and renders field errors and known domain codes inline, in the same data-error-for slots the client controller uses. One set of error slots serves both paths. The original React island had no fallback at all, so this no-JS path was a net gain from the rewrite. Multi-step flows that depend on prior client state can stay JS-only by design.

How do I lazy-load a React island in Astro with a custom client directive?

Register your own directive with addClientDirective in astro.config.mjs, pointing at an entrypoint that decides when to hydrate. On my site, client:interaction held hydration until intent: it listened for the first mouseenter, touchstart, or focusin on the island root, then called the loader once. A second directive, client:on-toast, waited for a custom trigger-toaster window event before loading the sonner toaster at all, so the toast library never shipped on page load. This genuinely deferred hydration off the initial load and lowered Total Blocking Time. The catch is that it does not remove the bytes: React and the island still download and execute, just later, and a user who goes straight for the widget pays the cost mid-interaction. That limitation is what pushed me from deferring hydration to removing the island.

Why use aria-activedescendant instead of focus for a custom multi-select in Astro?

Because moving DOM focus into the option list breaks form-validation timing. In the framework-free multi-select I built, keyboard navigation runs through aria-activedescendant: focus stays on the trigger while arrow keys move a highlighted option. If list items took real focus instead, opening the menu would blur the trigger, and a controller that validates on focusout would fire a "required" error the instant the menu opened, before the user picked anything. Keeping focus on the trigger stops the field from blurring mid-selection. The selected values mirror into hidden name="interests[]" inputs, which FormData collapses into an array for both the enhanceForm controller and a native no-JS POST. The visible trigger carries name="interests" as a type="button", so it never enters FormData but can still show aria-invalid.

We use cookies to analyze site traffic and improve your experience. By clicking "Accept All", you consent to our use of analytical tracking.