React and server components — tcm-ts

26. React and server components

Write React components in .tsx scratch files. tcm works out which ones render on the server and which in the browser, the way it places all code (web apps): there's no "use client" and no "use server".

// tcm: namespace notes
import { table } from "#codebase/notes/lib/server";      // the server library: tcm lib add tcm server
import { useState } from "react";

export const notes = table<{ text: string }>("notes");

export async function addNote(text: string): Promise<void> {     // a server function
  await notes.insert({ text });
}

export async function NoteList() {                                // async: renders on the server
  const all = await notes.find();
  return <ul>{all.map((n) => <li key={n.id}>{n.value.text}</li>)}</ul>;
}

export function NewNote() {                                       // state and a handler: renders in the browser
  const [text, setText] = useState("");
  return (
    <form onSubmit={async (e) => { e.preventDefault(); await addNote(text); setText(""); }}>
      <input value={text} onChange={(e) => setText(e.target.value)} />
    </form>
  );
}

export function App() {                                           // renders anywhere: here, in the browser
  return <main><NewNote /><NoteList /></main>;
}
tcm add notes.tsx
tcm dev notes.App

There's nothing to install: tcm provides React, React DOM and their types (packages).

tcm dev (and tcm build/tcm start) accept a component as the app. tcm renders its first paint on the server, then the browser takes it over (hydrates it) with React DOM (below). A main function that calls createRoot itself works too, rendered in the browser only.

26.1 Where a component renders

tcm view says, for every component, where it renders and why:

uses a hook (useState, React.useEffect, …), an event handler (onClick={…}), or the DOM, or is a class component
in the browser
is async, or uses the server (a table, node:*, the server library)
on the server
does neither
anywhere: where it's used
$ tcm view notes.NoteList
// notes.NoteList #umd74guw : () => Promise<Element>
// renders on the server: it is an async component; the browser fetches its rendered tree by hash

Rendering a component isn't calling it, so a JSX element doesn't pull a component's needs into the one that renders it. A server component can render <NewNote />, and a browser component can render <NoteList />. Each renders where it has to.

  • A server component used by browser code becomes a stub in the browser. The stub fetches the component's rendered tree by hash (POST /__tcm/rpc/<hash>, with its props as the argument) and renders that. Its props are checked against its parameter types, as a server function's arguments are. The tree is React's own wire format, Flight (text/x-component), rendered by React's server components environment: on the server, code runs with React's server build (the react-server condition), as in any React framework. So React's tools see server components as React's (debugging).
  • A browser component inside a server component is sent as a reference to its hash, with the props the server gave it. The browser already has its code: tcm dev lists these as "rendered in the browser inside server components".
  • A package's browser component inside a server component (React Aria's Button, a Radix primitive) is sent the same way, as a reference to the package's export: tcm reads the package's code, as it does to place your own (packages), and one whose module says "use client", or that uses hooks, contexts or class components, renders in the browser. The server doesn't load the package for it.
  • A component that renders anywhere (plain markup, like a Markdown renderer) renders on the server inside a server component and in the browser inside a browser component. It's the same definition and the same hash.
  • Server functions are called from browser components as usual: import them and await them.

tcm dev prints the split:

  server   1 component(s) rendered on the server, fetched by hash: notes.NoteList #umd74guw
  server   1 function(s) the browser calls by hash: notes.addNote #2w6iz4qg

26.2 Keeping server components current

After the page calls a server function, every server component on it fetches its tree again, so NoteList shows the new note without any wiring. The fetch runs in a transition: the old tree stays on screen until the new one is ready, and browser components inside it keep their state, as React keeps it for any re-render.

A server component's props come from the browser component rendering it. Change them (<NoteList search={q} /> with q in state) and it fetches the tree for the new props.

26.2.1 Components that render the same are kept

A server component whose code can't render differently for the same props is kept by them, for good. tcm works that out from the code, as it does for pure constants, with nothing to annotate: through everything it uses, it reaches nothing outside the codebase (the clock, randomness, the network, files, the environment), uses nothing of the server library's that reaches #tcm/host (tables, cookies, sessions, the request), uses npm packages only where their code is pure too (packages), and reads no state something else could change.

  • The browser asks for its tree with GET, the props in the URL (up to 4 KB of them), and it's served immutable: the browser, and any cache in between, keeps it.
  • The server renders it once per props, and keeps the newest thousand.
  • A server function call doesn't fetch it again: it can't have changed. The rest are fetched as above.

tcm build lists them (cacheable in tcm-app.json). One that reads a table is fetched each time, as it must be.

26.2.2 Pages are read before they're gone to

useAppLinks reads what a page of the app shows before a link to it is followed: when it's pointed at for a moment, focused or pressed. For an app served by tcm start, the server renders that page's server components for this request (GET /__tcm/page?path=…, as the page itself would render) and the browser keeps their trees for half a minute, or until a server function call. Following the link then renders at once. A static site reads the files it kept instead, and links in view too (static sites).

26.3 Loading and streaming

A server component's stub suspends while its tree is on the way, so a <Suspense> boundary above it shows its fallback when there's nothing shown yet (a re-fetch in a transition, as after navigating or a server function call, keeps what's there):

<Suspense fallback={<p>loading…</p>}>
  <NotePreview id={id} />
</Suspense>

A <Suspense> boundary inside a server component streams. Its fallback goes out with the rest of the tree, and what's inside follows when it's ready, so a slow part doesn't hold up the rest:

export async function Page() {
  return (
    <>
      <h1>Dashboard</h1>                                  {/* arrives at once */}
      <Suspense fallback={<p>loading stats…</p>}>
        <Stats />                                          {/* an async component: streams in when it's done */}
      </Suspense>
    </>
  );
}

An error inside a boundary goes to that boundary in the browser, so an error boundary around it can catch it. The rest of the tree is unaffected. In production the browser gets what any caller of the server gets: an HttpError's message and status, and for anything else only that it failed (the error is logged on the server). React puts that in the error's digest, as JSON ({ "name": "Error", "message": "internal error" }); in development the error comes with its message and its server stack.

26.4 The first paint

When the app is a component, tcm dev and tcm start render each page on the server and stream its HTML: the shell first (everything outside boundaries that are still waiting, with their fallbacks), then each boundary's contents. Server components render right there, in the page's request (its cookies and session). The trees they rendered go into the page, and the browser hydrates with them instead of fetching them again.

  • Browser components render on the server too, for the first paint, as React server rendering does: their initial state, without effects. Code that touches the DOM belongs in effects and event handlers.
  • A component can give the page its title by rendering a <title>: the page's own, in place of the app's name that the shell has.
  • location is the page's URL while the page renders on the server, so browser code that reads it (a router, say) renders the same on both sides. useSyncExternalStore needs a server snapshot to render on the server; () => location.search works for both. Its updates always block, though, so for the URL, useLocation is better (navigating is a transition).
  • If the shell can't render on the server (a browser component reads window while rendering), tcm logs why and sends the page without it; the browser renders it, as it would without server rendering. A boundary that fails on the server is rendered in the browser instead.
  • Until the page hydrates, clicks do nothing: the markup is there before the code that handles it.
  • The page's code is on its way first. tcm start answers a page with Early Hints (103) naming its chunks, then what React finds the page needs before its shell is ready (its Link header: preload(), fonts, styles), so the browser fetches them while the page renders. (Browsers act on Early Hints over HTTP/2 and HTTP/3, as a proxy in front of tcm start usually serves.)

26.4.1 Shells are rendered once

tcm start renders a page's shell once and keeps it, as partial prerendering does: what renders the same for every request (browser components' first paint, and server components that render the same for the same props), with the fallbacks of the <Suspense> boundaries around what's live (server components that read tables, cookies or the clock). A request gets the shell at once, and React renders only what's live for it, streamed after the shell into those boundaries. Nothing says which is which: it's what tcm works out from the code.

  • A shell is kept per address (path and query) and per build, so a deploy renders them again; the newest thousand are kept.
  • Something live outside any <Suspense> holds up the whole shell: that page renders whole, each time, as before. Put a boundary around what's live to give it a shell.
  • Not when the app's browser code reads the clock or randomness (Date.now(), Math.random(), anywhere in it): a kept first paint would show the moment it was rendered. tcm build says whether its shells are kept (shells in tcm-app.json), and tcm start logs each it renders.

tcm build makes a server-rendering bundle along with the browser one, kept in the build (server/) but never served. tcm build --static renders every page with it as it builds, server components included, for a static site (static sites).

26.5 What crosses

A server component's props, from the browser component rendering it, are plain data, the same as server function arguments (web apps). What a server component gives the browser components it renders is what Flight sends: plain data, Date, Map, Set, BigInt, typed arrays, and elements (children, or sidebar={<Nav />}), rendered on the server first.

A server function can be given as a prop, with arguments bound or not, and the browser component calls it as it would import and call it: by hash, its result awaited, and the page's server components fetched again after it.

export async function NoteList() {
  const notes = await loadNotes();
  return notes.map((n) => <NoteRow key={n.id} note={n} onDelete={deleteNote.bind(null, n.id)} />);
}

Only a server function given away like that is the browser's to call: one the server component only calls (loadNotes) stays on the server, and asking for it by hash is refused. tcm dev lists the ones the browser can call. Any other function can't be sent; React refuses it, saying which prop it was:

A function can't be sent from a server component to a browser component: the browser component can import what it
needs itself.
  <... onPick={function onPick}>

26.6 Debugging server components

Server components are React's own, so React's tools work on them as in any React framework. In tcm dev:

  • React DevTools shows them in the component tree, with the browser components inside them, marked as rendered on the server, with their props and how long they took.
  • Their stacks are readable. An error's stack, and the owner stack React gives a warning, run through server components, by name, and a frame opens their code: tcm dev serves each module's source, named by the definition's name (codebase/notes.NoteList.tsx).
  • What they log is said in the browser's console too, marked as the server's, as well as in tcm dev's output.

tcm start and tcm build --static run React's production builds, whose trees carry none of this: no stacks, paths or logs reach the browser.

Both tcm dev and tcm start say how long rendering took, in each response's Server-Timing (the browser's network panel shows it): render for a page's server rendering and a server component's tree, the time until its first part went out (what's inside a <Suspense> boundary may come after), and call for a server function.

26.7 React Compiler

A component app's components and hooks are memoized by React Compiler as it's built, so a re-render does only what changed, with nothing to set up: no memo, useMemo or useCallback to write. It's oxc's port of the compiler (oxc-transform-react), which comes with tcm, as oxlint does (tcm upgrade oxc-transform-react@<range> chooses another for a codebase).

  • Each module is compiled once: kept in .tcm/compiled by its text and the compiler's version, so a build, or tcm dev's reload, compiles only what changed. The browser's bundle and the server's first paint are compiled alike, so they hydrate, and browser tests render components compiled, as the app does.
  • What the compiler can't make sure of, it leaves as written: a component that breaks the rules of React, or that says "use no memo". A module the compiler can't take at all is bundled as written.
  • Linting checks the same rules (oxlint's React rules: purity, hooks), so what the compiler would leave alone is said as you save.

26.8 Packages and versions

JSX compiles to calls into react/jsx-runtime (or your tsconfig's jsxImportSource). That runtime is pinned like an import, by compatible range: a component's hash says it was written against React 19, and the codebase runs one React 19.x for all of them, with React DOM at the matching version, so there's one React (packages).

26.9 Trying components out

Components are values, so a watch expression can render one:

import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "#codebase/notes";

renderToStaticMarkup(<Markdown text="**hi**" />);    // ⧩ '<div class="markdown"><p><strong>hi</strong></p></div>'

The notes example is a complete app: a sidebar and note page rendered on the server, an editor with a live preview in the browser, and search.

What browser components keep needing is a library that tcm ships, react: tcm lib add tcm react mounts it read-only at <root>.lib.react, with no network, and code imports it from there. It's versioned by its releases, so an app keeps the one it has until tcm lib upgrade react moves it to the one the tcm you run ships (upgrading). It works with the React tcm provides (19), and its README (tcm view shop.lib.react.README) says how to use it.

$ tcm lib add tcm react
added shop.lib.react: react@0.1.0 from tcm (22 names; 22 definitions received, verified by re-hashing)
// tcm: namespace shop
import { match, navigate, refresh, useAppLinks, useData, useLocation } from "#codebase/shop/lib/react";
import { listProducts, Product } from "#codebase/shop";

export function App() {
  useAppLinks();                                       // links to the app are followed without a reload
  const at = useLocation();                            // re-renders when the path changes
  const product = match("/p/:id", at.path);
  return product ? <Product id={product.id} /> : <Products sort={at.query.get("sort") ?? "new"} />;
}

export function Products({ sort }: { sort: string }) {
  const list = useData(`products:${sort}`, () => listProducts(sort), { keep: true });
  if (list.loading) return <p>loading…</p>;
  return <ul>{list.value?.map((p) => <li key={p.id}><a href={`/p/${p.id}`}>{p.name}</a></li>)}</ul>;
}
useLocation()
the page's path, query (URLSearchParams), hash and href; the component re-renders when they change (back, forward, navigate). While the server renders the first paint it's the page's URL, so the first paint is the route's page
navigate(to, { replace?, scroll? })
go to a path of the app without reloading (another site's URL is loaded). It's a transition (below). Once the next page is on screen, it scrolls to the top, or to the fragment, unless replace
useNavigating()
whether a navigation (navigate, back or forward) is still rendering: the next page is on its way and the last one is still on screen. For a sign that something's happening, best shown after a short delay so quick ones don't flicker
href(path)
a path of the app as a link's address: under the app's base when it's served under a path of a site (deploying), else the path itself. <a href={href("/p/1")}>; for React Aria, <RouterProvider navigate={navigate} useHref={href}>
match(pattern, path)
a path's parameters (/p/:id, /docs/*), decoded, or undefined. It runs anywhere
useAppLinks(isApp?)
follow the page's <a href> links to the app without reloading: from any component, server ones and Markdown too. Links with a modifier key, a target, download or rel="external" are the browser's, and so are paths isApp turns down. What a page shows is read before it's followed, when a link to it is pointed at, focused or pressed (pages are read before they're gone to)
useData(key, read, { keep? })
read something once per key while the page is open. key names what read reads, so everything it depends on belongs in it. Already read (going back to a page), it's shown at once. keep shows the previous key's value (stale) while the next is read, so changing a filter doesn't empty the page. It gives value, error, loading, refreshing, stale and refresh()
<Pages routes={…} keep? notFound? />
the page the path matches (routes by pattern, as match reads them: { "/": Home, "/p/:id": Product }; each gets params and query). The last keep pages left (3) stay mounted but hidden, in React's <Activity>, so going back shows one at once with its state (what was typed, what was opened); and a page a link to is pointed at or pressed is rendered ahead, hidden, at low priority, its server components' trees fetched, so following the link shows it at once. The first paint is the page shown, alone
refresh(prefix?)
read again, after a change, what's shown whose key starts with prefix, keeping it on screen until it's read. What isn't shown is read when it is
useAppUpdate()
{ ready, reload }: whether a PWA's new version has been taken in by its service worker, so the page's next load is it; reload() loads it now. Always false for an app that isn't a PWA
<Stylesheet css={…} precedence? />
CSS for the page: a stylesheet of the codebase is linked to as a file the app's build serves, and the page waits for it rather than show unstyled (below). It runs anywhere

The first paint shows useData as loading: it reads in the browser. Data a server component reads is rendered on the server instead (above). Using any of them but match, href and <Stylesheet> makes a component render in the browser, as a hook does: the library's code is placed as any code is.

Code importing #tcm/react, the module tcm used to generate for this, is refused with how to use the library instead: mount it, and import from #codebase/<root>/lib/react. Under a base, useLocation().path, navigate and useAppLinks work with the app's paths (/ is the base), and links to other paths of the site are left to the browser. React Aria's links follow its RouterProvider: give it navigate.

26.10.1 Stylesheets

<Stylesheet css={styles} /> puts CSS in the page through React's own stylesheets (precedence): once however many components render it, kept when they're gone, ahead of the page's content in the server's first paint and in <head> when the browser adds one. Give it a stylesheet of the codebase, a .css file the code imports (files are values):

// tcm: namespace shop
import { Stylesheet } from "#codebase/shop/lib/react";
import { styles } from "#codebase/shop";   // shop.styles, a .css file

export function App() {
  return <><Stylesheet css={styles} /><Products sort="new" /></>;
}

The app's build serves it as a file of its own (/_app/assets/styles-<hash>.css, named by its hash, and the same in every build that has it), and <Stylesheet> links to it rather than write it into the page:

  • Nothing shows unstyled. The server's first paint has the link ahead of what it styles; a <Suspense> boundary streamed in after it waits for the stylesheets it renders; and a navigation (a transition) keeps the last page on screen until the next page's stylesheets have loaded.
  • It's fetched early. While a shell that waits on data renders, React names it in the page's Link header, sent as Early Hints (the first paint).
  • The browser keeps it, across pages and deploys that don't change it (immutable); one that changed is sent as what changed, as chunks are (deploying).
  • It's sent once, as CSS. When the browser's code only gives it to <Stylesheet css={styles} /> (the file itself, not a name of its own for it), its text isn't in the code: the module there holds a stand-in that <Stylesheet> links to, by the stylesheet's name (styles.css), and the page names the file it is now (__TCM_STYLESHEET_FILES__), as the import map names chunks. So a change to the stylesheet changes only its file and the page, not the chunk of the code that renders it (two the app links to that share a name are named by their files instead). Code that uses its text otherwise (its length, put in a string, given to another component) gets the text, as written.

precedence ("default") groups stylesheets: the groups go in the order React first meets each, so a stylesheet of a precedence met later wins over one met earlier. Other CSS (a computed string, or one rendered outside an app's build, as a test does) is written into the page, as a <style> React keeps once by its content. So is a stylesheet only server components render, which the browser's code doesn't have: the file is served for the browser's code.

navigate, and the back and forward buttons, re-render the page in a transition. What's on screen stays until the next page is ready: a server component fetching its tree for new props, or anything else that suspends, holds the old page rather than showing its <Suspense> fallback. A fallback shows only where nothing was shown before. Without this, every navigation that suspends flashes its fallbacks, for at least React's 300ms reveal throttle however quickly the data comes. The library keeps its state in React state, never useSyncExternalStore, whose updates always block.

Only what changes is fetched again. A server component whose props don't change keeps its tree, so a list doesn't take the selected item as a prop: the browser component for each entry reads the URL itself.

export async function NoteList({ search }: { search: string }) {    // fetched again when the search changes
  const notes = await listNotes(search);
  return <ul>{notes.map((n) => <Entry key={n.id} note={n} />)}</ul>;
}

export function Entry({ note }: { note: NoteSummary }) {              // re-renders when the URL does
  const selected = useLocation().query.get("note") === note.id;
  return <li className={selected ? "selected" : undefined}>{note.title}</li>;
}

26.10.3 View transitions

Because navigating is a transition, React's <ViewTransition> animates it. Wrap what changes, and keep <Suspense> outside a <ViewTransition> that's keyed by the page, so the boundary stays and holds the last page while the next is read:

<Suspense fallback={<p>loading…</p>}>
  <ViewTransition key={noteId}>                    {/* the note out, the next one in: a cross-fade */}
    <NotePreview id={noteId} />
  </ViewTransition>
</Suspense>

Each navigation's transition has types, for animations that differ by direction: navigate, and navigate-forward (navigate, the forward button) or navigate-back (the back button). Give <ViewTransition> a class per type (enter={{ "navigate-back": "slide-right", default: "auto" }}) and style it in CSS. A transition started in a popstate event renders at once in React, fallbacks included and with no view transition, so the library starts the back and forward buttons' just after it.

26.11 cache and cacheSignal

React's cache keeps what a function returns for the arguments it's given, and cacheSignal says when the work it's part of is no longer needed. React's own work only in its server components' build, which tcm doesn't use; in the one it does, cache keeps nothing. So tcm gives code that imports them from react its own, which work in the server code it runs:

  • What cache keeps lasts the request: a page rendered on the server (every server component on it, its shell's included), a server component's tree, a server function call. Arguments are compared as React's are: values by value, objects by identity. Two server components on a page asking for the same thing ask once.
  • cacheSignal() is the request's signal, aborted when the browser goes away: pass it to fetch to stop work nobody will see.
  • Anywhere else (the browser, a test) they're React's: cache keeps nothing, cacheSignal() is null.
export const page = cache(async (id: string) => db.find(id));     // once per request, however many ask

export async function Title({ id }: { id: string }) {
  return <h1>{(await page(id)).title}</h1>;
}

The docs site's server components share its pages' rendering so.

26.12 Contexts

A server component can provide a context to the browser components it renders:

export const Theme = createContext("light");          // a top-level definition, so it has a hash

export async function Layout() {
  const prefs = await loadPrefs();
  return <Theme value={prefs.theme}><Toolbar /></Theme>;   // Toolbar reads it with useContext
}

The provider is sent as a reference to the context's hash, with its value, which must be data Flight sends (what crosses).

A server component can't read a context, as in React's server components anywhere: useContext is a hook, and a <Theme.Consumer> needs a function child, which can't be sent. The browser components under the provider read it. Nor can a server component see providers in browser components above it: it renders on the server, before the browser has any. Pass what it needs as props.

26.13 Code splitting

A lazy component a server component renders loads and renders in the browser. It's sent as a reference to its definition's hash, like a browser component. What only its import() reaches is split out of the page's chunks into chunks of its own (tcm build), so a page that doesn't show it doesn't load it. A page that does starts loading them with its HTML: server rendering puts them in the page as <script type="module">s, as React's Flight says. In the browser, its code starts loading as soon as a tree naming it arrives, or is read before its page is gone to, not when React gets to it:

export const Chart = lazy(() => import("chart-lib"));     // a top-level definition, so it has a hash

export async function Report() {
  const points = await loadPoints();
  return <Chart points={points} />;                        // the browser loads chart-lib, then renders it
}

The first paint renders it on the server, as React server rendering does.

26.14 Cookies

A server component can set cookies (setCookie from the server library), when it's fetched and when it renders the first paint. Cookies set before the page's headers go out are in them. Ones set later (in a <Suspense> boundary that streams after the shell) are kept under a one-time token in the page, and the browser collects them right away (POST /__tcm/cookies/<token>, within a minute), so httpOnly cookies work too.

built from c482a14e