Tutorial 2 of 36 · for anyone new to server islands · Markdown

Build a counter

You'll add a working button. The browser downloads no component code for it. Clicking it sends the event to Rust, Rust runs the handler and sends back the markup that changed.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start from the app you made in 010. A fresh fsr new hello --with react works too; --with react adds React, since a plain fsr new writes no framework.

Write a normal component#

This is an ordinary component: a useState, two buttons and the handlers you would write anywhere.

app/src/ui/Counter.tsx
import { useState } from "react";

export function Counter({ start }: { start: number }) {
  const [n, setN] = useState(start);
  return (
    <div className="counter">
      <p>Count: {n}</p>
      <button onClick={() => setN(n + 1)}>Add one</button>
      <button onClick={() => setN(start)}>Reset</button>
    </div>
  );
}

Save that as app/src/ui/Counter.tsx.

Place it as a server island#

The Island wrapper decides where the component runs. mode="server" means the handlers run in Rust.

app/routes/page.tsx
import { Island } from "@snapfire/fsr-client/react";
import type { RootProps } from "@generated/client";
import { Counter } from "@src/ui/Counter";

export default function Index({ greeting }: RootProps) {
  return (
    <section className="hero">
      <h1>{greeting}</h1>
      <Island when="load" mode="server">
        <Counter start={0} />
      </Island>
    </section>
  );
}

Build it#

$ fsr build hello/app --verbose
rendered  routes/page.tsx#default            lowered
          src/ui/Counter.tsx#Counter         lowered
islands   src/ui/Counter.tsx#Counter         server      2 handlers
plan      generated/plan.sexp    2.1 KB written
typecheck tsc 7.0.2 from cache, clean

2 handlers means the build understood both onClick bodies well enough to run them in Rust. They are now in the plan beside the state they touch.

Look at what the browser gets#

Run fsr dev hello/app, open the page and read the source. The buttons carry data-sf-on="click:0" and click:1. The region carries data-sf-mode="server". The network panel shows no module for Counter and there is no React root for it.

Click the button. One request goes to /_sf/island/… and comes back with the new count and the markup around it. The browser patches in what changed. While the request is out the island carries data-sf-pending, which is your hook for styling a busy state.

LiveThe counter from this page, placed as a server island

Count: 0

Open the network panel and click it here. Each click is one request to /_sf/island/… and no module for Counter loads.

What a server handler can do#

The build has to be able to run the handler in Rust, so a handler is consts and calls to your state setters:

onClick={() => setN(n + 1)}
onClick={() => setN((prev) => prev - 1)}
onChange={(e) => setQty(Number(e.target.value))}

e.preventDefault() is allowed and dropped. Anything else is refused at build time with the line it's on, so it can't fail silently at runtime. A component nested inside a server island can't have its own state or handlers either, because the round trip carries exactly one state: the island's.

When to use it#

Each event costs a round trip and there is no optimistic update. A toggle, a quantity stepper, a filter or a sort order works well this way. A text field somebody types into continuously doesn't, because every keystroke would be a request. Take mode="server" off and the same component hydrates as React instead, with the module loaded.

Next up: 030. Add a working cart, where the state moves out of the component and onto the server.