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 (thereact-servercondition), 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 devlists 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
Markdownrenderer) 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
awaitthem.
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 servedimmutable: 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. locationis 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.useSyncExternalStoreneeds a server snapshot to render on the server;() => location.searchworks for both. Its updates always block, though, so for the URL,useLocationis better (navigating is a transition).- If the shell can't render on the server (a browser component reads
windowwhile 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 startanswers a page with Early Hints (103) naming its chunks, then what React finds the page needs before its shell is ready (itsLinkheader: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 oftcm startusually 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 buildsays whether its shells are kept (shellsintcm-app.json), andtcm startlogs 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 devserves 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/compiledby its text and the compiler's version, so a build, ortcm 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.
26.10 Routes, links and data: the react library
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),hashandhref; 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, atarget,downloadorrel="external"are the browser's, and so are pathsisAppturns 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.
keynames whatreadreads, so everything it depends on belongs in it. Already read (going back to a page), it's shown at once.keepshows the previous key's value (stale) while the next is read, so changing a filter doesn't empty the page. It givesvalue,error,loading,refreshing,staleandrefresh() <Pages routes={…} keep? notFound? />- the page the path matches (
routesby pattern, asmatchreads them:{ "/": Home, "/p/:id": Product }; each getsparamsandquery). The lastkeeppages 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
Linkheader, 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 (itslength, 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.
26.10.2 Navigating is a transition
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
cachekeeps 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 tofetchto stop work nobody will see.- Anywhere else (the browser, a test) they're React's:
cachekeeps 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.