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
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.
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.
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.
Related
- Passing Signals Between Fresh Islands β sharing state once you have more than one island.
- Fresh and Deno Islands β the directory convention and what it implies.
- Reducing JavaScript Payload in Islands Apps β shrinking the module the move produced.
β Back to Fresh and Deno Islands