Tutorial 11 of 36 · for anyone coming from Next who writes async function Page() and lazy() · Markdown

Port async components from Next.js

You'll write a report page the way Next's App Router teaches, with an async component that awaits its data. fsr will refuse it and tell you where the await goes. Then you'll split the chart into a component that loads on demand with React.lazy. fsr still renders it on the server, so the first paint has the finished chart.

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.

Two things called an async component#

Next uses "async component" for a component that awaits while it renders. React and Vue use it for a component whose code loads on demand, lazy() and defineAsyncComponent(). fsr refuses the first and supports the second. This tutorial does both, in that order.

The chart#

$ fsr new sales --with react

A bar chart with one button that sorts it, in app/src/ui/Bars.tsx:

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

export type Row = { region: string; total: number };

export default function Bars({ rows }: { rows: Row[] }) {
  const [sorted, setSorted] = useState(false);
  const shown = sorted ? [...rows].sort((a, b) => b.total - a.total) : rows;
  const max = Math.max(...rows.map((r) => r.total));
  return (
    <div className="bars">
      <button onClick={() => setSorted(!sorted)}>{sorted ? "Original order" : "Sort by total"}</button>
      {shown.map((r) => (
        <div key={r.region} className="bar">
          <span>{r.region}</span>
          <span className="fill" style={{ width: `${(r.total / max) * 100}%` }}>
            {r.total}
          </span>
        </div>
      ))}
    </div>
  );
}

A chart section that loads Bars on demand, in app/src/ui/Chart.tsx:

app/src/ui/Chart.tsx
import { lazy } from "react";
import type { Row } from "@src/ui/Bars";

const Bars = lazy(() => import("@src/ui/Bars"));

export default function Chart({ rows }: { rows: Row[] }) {
  return (
    <section>
      <h2>Sales by region</h2>
      <Bars rows={rows} />
    </section>
  );
}

There's no <Suspense> around <Bars>. In a browser-only React app you'd need one, with a fallback to show while the module downloads. You'll see below why fsr doesn't.

The data comes from an async function, standing in for a database or an API, in app/src/sales.ts:

app/src/sales.ts
import type { Row } from "@src/ui/Bars";

export async function salesByRegion(): Promise<Row[]> {
  return [
    { region: "North", total: 420 },
    { region: "South", total: 910 },
    { region: "East", total: 260 },
    { region: "West", total: 640 },
  ];
}

Some styling, at the end of app/styles/app.css:

app/styles/app.css
.bars {
  display: grid;
  gap: 8px;
}

.bar {
  align-items: center;
  display: grid;
  gap: 12px;
  grid-template-columns: 80px 1fr;
}

.fill {
  background: #2f80ed;
  border-radius: 4px;
  color: #fff;
  padding: 2px 8px;
}

The page the Next way#

This is how you'd write it in the App Router: the page awaits its own data.

app/routes/report/page.tsx
import Chart from "@src/ui/Chart";
import { salesByRegion } from "@src/sales";

export default async function Report() {
  const rows = await salesByRegion();
  return (
    <article>
      <h1>Quarterly report</h1>
      <Chart rows={rows} />
    </article>
  );
}

There's no "use client" anywhere. Bars has a button with state, so it has to run in the browser, but the build works that out from the component itself.

$ fsr dev sales/app
routes/report/page.tsx:4:16: an `async` component; what it awaits belongs in the route's loader, `page.loader.ts`, which runs before the page renders
dev: waiting for changes

The build stops at the page rather than sending it to the browser. fsr dev waits for you to fix it.

Move the await#

Do what it says. The await goes into a loader beside the page:

app/routes/report/page.loader.ts
import type { Ctx } from "@snapfire/fsr";
import { salesByRegion } from "@src/sales";

export async function load(_ctx: Ctx<"/report">) {
  return { rows: await salesByRegion() };
}

The page takes the data as props and stops being async:

app/routes/report/page.tsx
import type { ReportProps } from "@generated/client";
import Chart from "@src/ui/Chart";

export default function Report({ rows }: ReportProps) {
  return (
    <article>
      <h1>Quarterly report</h1>
      <Chart rows={rows} />
    </article>
  );
}

ReportProps is generated from what the loader returns, so the page is typed against it without you writing the type. Save both files and the server starts.

The split isn't style. Every loader under a route starts before anything renders, so a layout's data and a page's data load side by side. An await inside a render runs in tree order instead, each level waiting for its parent. That's the waterfall Next apps fix with Promise.all and preloading. A render that only reads its props can also be cached by what it read. A render that fetches mid-way can't.

The lazy part renders on the server#

Open http://127.0.0.1:3000/report and view the page source. The bars are there before any script runs:

$ curl -s http://127.0.0.1:3000/report | grep -o '<div class="bar"><span>South</span>[^/]*'
<div class="bar"><span>South</span><span class="fill" style="width:100%">910<

fsr followed import("@src/ui/Bars") at build time and lowered Bars like any other component. So the server rendered it, lazy() or not. The first paint has the finished chart. Nothing needs a fallback, because there's never a moment with nothing to show.

The module still loads on demand. Open the network panel and load /: Bars.js isn't requested. Load /report and it arrives once the page is up, when Bars mounts over the markup it already has. Chart.js never arrives at all: Chart only renders a heading and places Bars, so the build kept it on the server and cut the island at Bars, the part with state. Click "Sort by total" and South jumps to the top.

Try it#

Make Chart async instead of the page: export default async function Chart with an await salesByRegion() inside. It's refused the same way, at the line of the async. Any component that awaits while it renders is refused, not just pages.

Then make the whole chart one island instead of letting the build pick. Wrap it in the page:

app/routes/report/page.tsx
import type { ReportProps } from "@generated/client";
import { Island } from "@snapfire/fsr-authoring/template";
import Chart from "@src/ui/Chart";

export default function Report({ rows }: ReportProps) {
  return (
    <article>
      <h1>Quarterly report</h1>
      <Island>
        <Chart rows={rows} />
      </Island>
    </article>
  );
}

Reload /report with the network panel open. Chart.js now arrives as well as Bars.js, since the heading is part of the island too. The page looks the same; only what the browser downloads changed. Take the Island back out.