Converting a Preact Component into a Fresh Island

Making a component interactive in Fresh looks like a file move, and it is β€” but the move changes what the component’s imports cost, because everything it reaches now ships to the browser. This walkthrough converts one component properly: splitting off just the interactive part, auditing what came with it, and measuring the result. The convention itself is covered in Fresh and Deno islands.

Prerequisites

Split First, Then Move On the left, the whole product card is moved into the islands directory: its markup, its price formatting and its image handling all become browser code, producing a thirty-one kilobyte module. On the right, only the favourite button is moved: the card stays server-rendered and the island is under two kilobytes, with identical behaviour for the visitor. The same feature, two boundaries MOVE THE CARD title, image, price, badges β€” all client code currency formatting comes along module: 31 KB one control made interactive, a whole card shipped MOVE THE BUTTON card stays server-rendered β€” 0 KB island: 1.8 KB identical behaviour for the visitor

Implementation Steps

Step 1 β€” Split the component before moving anything

Extract the subtree that owns state or handlers into its own component, leaving the presentational parent behind. In practice this means a new file with a narrow prop signature β€” an id, a current value, a label β€” and a parent that renders it.

// components/ProductCard.tsx β€” stays in the server zone.
import FavouriteButton from "../islands/FavouriteButton.tsx";

export default function ProductCard({ product, isSaved }: Props) {
  return (
    <article class="card">
      <img src={product.image} alt="" width="320" height="240" />
      <h3>{product.title}</h3>
      {/* Formatted on the server: the browser gets a string, never Intl. */}
      <p class="price">{product.priceFormatted}</p>
      {/* The only interactive part, and the only thing that ships. */}
      <FavouriteButton productId={product.id} initiallySaved={isSaved} />
    </article>
  );
}

Step 2 β€” Move the file and audit what followed it

// islands/FavouriteButton.tsx β€” client zone. Every import here ships.
import { useSignal } from "@preact/signals";
// NOT: import { formatPrice } from "../utils/format.ts";  ← would pull the
// locale bundle into the browser for a value the server already formatted.

export default function FavouriteButton(
  { productId, initiallySaved }: { productId: string; initiallySaved: boolean },
) {
  const saved = useSignal(initiallySaved);

  async function toggle() {
    saved.value = !saved.value;                       // optimistic
    const res = await fetch(`/api/favourites/${productId}`, {
      method: saved.value ? "PUT" : "DELETE",
    });
    if (!res.ok) saved.value = !saved.value;          // roll back on failure
  }

  return (
    <button type="button" aria-pressed={saved.value} onClick={toggle}>
      {saved.value ? "Saved" : "Save"}
    </button>
  );
}

The commented-out import is the point of the exercise. Before the move it cost nothing; after the move it would be the largest thing in the module.

Step 3 β€” Keep a server-rendered baseline

The button above requires JavaScript to do anything. If saving matters without it, render a form posting to the same endpoint and let the island intercept the submit instead β€” the pattern described in progressive enhancement in modern frameworks. This is a product decision, but it should be a deliberate one rather than a side effect of how the component was written.

The Import Audit Three categories of import inside island code. Safe: the framework's own signal and hook modules, plus small pure helpers. Suspicious: shared utility barrels and formatting libraries, which usually indicate work that belongs on the server. Forbidden: anything reading the environment, the filesystem or a database, which either fails to build or silently misbehaves. Read the island's imports as a shipping manifest safe signals, hooks, small pure helpers written for this component a few hundred bytes each, and they are the reason the island exists suspicious utility barrels, date and currency libraries, validation schemas usually a sign that formatting or validation should happen on the server forbidden database clients, environment readers, filesystem access fails to build, or resolves and misbehaves β€” pass the value as a prop instead

Verification

Count the requested modules. Load the route with an empty cache. You should see the runtime plus exactly one module for this island. A second unexpected module means something else was dragged in as a shared chunk.

Check the transferred size against your budget. Under a couple of kilobytes for a button-sized island is normal; anything above ten deserves a look at the import graph.

Confirm the server markup is still the source of truth. View source and find the button. It should be present, labelled correctly, and reflect the saved state β€” if the server renders an empty container that the island fills, the split was drawn in the wrong place.

Time the activation. A mark at mount tells you whether the module’s cost is transfer or execution. A large module that mounts instantly is a transfer problem; a small module that takes eighty milliseconds is doing work on mount that probably belongs on the server.

What the Conversion Should Produce Four measurements for a well-executed conversion. Modules requested on the route rises by exactly one. Transferred JavaScript rises by under two kilobytes. Blocking time is unchanged because the island activates on interaction. Server markup for the control remains present, so the page still renders the button without JavaScript. MEASURE EXPECTED AFTER THE MOVE modules requested exactly one more than before transferred JavaScript under 2 KB for a control-sized island blocking time at load unchanged β€” activation is not at load server markup for the control: still present in view-source

After the First Conversion

The first conversion teaches the pattern; the tenth is where the discipline shows. Two habits keep a project from accumulating oversized islands.

Review the islands directory as a list, periodically. It is the manifest of everything your pages ship, and reading it as one page β€” names and sizes side by side β€” surfaces the entries that grew. An island called ProductCard is a smell on its own, because the name says it contains more than a control.

Add the module size to code review. A build step that writes each island module size into a committed file turns β€œthis component got bigger” into a diff line, which is the only reliable way to notice. The alternative, discovering it in a quarterly performance review, means the regression has been shipping for months and the change that caused it is hard to identify. The same budget mechanism described in reducing Qwik serialization payload size applies unchanged here β€” the numbers differ, the practice does not.

Troubleshooting

Why did the module get so large after the move?

Because the component's imports came with it. A formatting helper that imports a locale bundle, or a shared module that re-exports half the codebase, was previously free β€” it ran on the server and never left it. Inside an island the same import becomes browser code. Read the compiled module's import graph rather than the source file: the culprit is almost always a barrel file re-exporting far more than the one function you wanted.

Should the whole component move, or only part of it?

Only the part that owns behaviour. A card with a title, a price and a favourite button should keep the title and price on the server and make the button the island. Moving the whole card ships its markup, its formatting and its children for the sake of one control. The split is a little more code and usually an order of magnitude less JavaScript.

What breaks if the component read something server-only?

It fails at build with a resolution error if the import cannot run in a browser, which is the good case. The bad case is a module that resolves but behaves differently β€” a configuration object that reads environment variables and silently produces undefined. Pass those values in as props from the route, where the server can still read them, and let the island receive plain data.

← Back to Fresh and Deno Islands