React and server components
Write React components in .tsx scratch files. tcm works out which render on the server and which in the browser,
as it places all code (web apps): no "use client", 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
tcm provides React 19 and its types (packages). tcm dev, tcm build and tcm start take a
component as the app: rendered on the server, hydrated in the browser. React is optional: an app can be a main in
plain TypeScript and the DOM (web apps); a main calling createRoot renders in the browser only.
Where a component renders
- uses a hook, 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
- where it's used
tcm view says where and why; tcm dev lists the split when it starts.
$ 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 server and browser components can render each other freely:
| rendered inside | a | becomes |
|---|---|---|
| browser code | server component | a stub that fetches its rendered tree by hash (POST /__tcm/rpc/<hash>), its props checked against its parameter types |
| a server component | browser component | a reference to its hash, with its props |
| a server component | package's browser component ("use client", or hooks, contexts, classes: packages) | a reference to the package's export; the server doesn't load the package |
| either | component that renders anywhere | rendered on that side: same definition, same hash |
Browser components call server functions as usual: import them and await them.
Keeping server components current
Server components fetch their trees again after a server function call (so NoteList shows the new note) and when
their props change. The fetch is a transition: the old tree stays until the new one is ready, and browser components
inside keep their state.
Components that render the same are kept
tcm finds, from the code, the server components that render the same for the same props: through everything they use, they reach no clock, randomness, network, files, environment, tables, cookies, sessions or request, no impure npm code (packages), and no mutable state (pure constants work alike).
Such a component is fetched with GET, its props in the URL (up to 4 KB), and served immutable. The server renders
it once per props (keeping the newest thousand), and a server function call doesn't refetch it. tcm build lists
them (cacheable in tcm-app.json).
Pages are read before they're gone to
useAppLinks reads a page when a link to it is hovered, focused or pressed: under tcm start the server renders its
server components (GET /__tcm/page?path=…), kept 30 seconds or until a server function call, so the link opens at
once. A static site reads its files instead, for links in view too (static sites).
Loading and streaming
A server component's stub suspends while its tree loads, so a <Suspense> above it shows its fallback where nothing
is shown yet. A <Suspense> inside a server component streams: the fallback goes out with the tree, and the slow
part follows.
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's error boundary in the browser. In production it carries an
HttpError's message and status, or else only internal error (in its digest, as JSON), and the server logs it.
In development it carries the message and server stack.
The first paint
When the app is a component, tcm dev and tcm start render each page on the server and stream it: the shell, then
each <Suspense> boundary's contents. Server components render in the page's request (its cookies and session), and
the browser hydrates with their trees.
- Browser components render too, with their initial state and no effects. Touch the DOM in effects and handlers.
- A
<title>a component renders is the page's title. locationis the page's URL on the server, so a router renders the same on both sides. For the URL, useuseLocationrather thanuseSyncExternalStore, whose updates block (navigating).- If the shell can't render on the server (it reads
window), tcm logs why and the browser renders it. A boundary that fails on the server renders in the browser. - Clicks do nothing until the page hydrates.
- The code comes first.
tcm startsends Early Hints (103) naming the page's chunks, then what React finds it needs (preload(), fonts, styles). Browsers use them over HTTP/2 and HTTP/3.
Shells are rendered once
tcm start keeps each page's shell, as partial prerendering does: what renders the same for every request, with
fallbacks around what's live (server components that read tables, cookies or the clock). A request gets the shell at
once, and only the live parts render.
- Kept per path and query, per build (the newest thousand).
- Something live outside any
<Suspense>makes the page render whole each time: wrap it in a boundary. - No shells if the app's browser code reads the clock or randomness (
Date.now(),Math.random()).tcm buildsays whether shells are kept (shellsintcm-app.json).
tcm build --static renders every page, server components included, for a static site.
What crosses
- Into a server component: plain data props, as for server functions (web apps).
- Out of one: plain data,
Date,Map,Set,BigInt, typed arrays, elements (children,sidebar={<Nav />}), and server functions, bound or not, which the browser calls by hash.
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 passed like that is callable from the browser; loadNotes stays on the server. Any other
function is refused, naming the prop:
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}>
Debugging server components
In tcm dev, React's tools treat server components as React's own:
- React DevTools shows them in the tree, marked as server-rendered, with props and timings.
- Error and owner stacks name them, and frames open their source (
codebase/notes.NoteList.tsx). - Their logs appear in the browser's console, marked as the server's.
tcm start and tcm build --static send none of this to the browser. Both tcm dev and tcm start add a
Server-Timing header: render (until the first part went out) and call.
React Compiler
Components and hooks are memoized by React Compiler as the app is built, so
you needn't write memo, useMemo or useCallback. tcm ships oxc's port, oxc-transform-react;
tcm upgrade oxc-transform-react@<range> picks another version. Modules are compiled once (.tcm/compiled), and the
browser, the first paint and browser tests all run compiled code. A component that breaks the rules of
React or says "use no memo", and a module the compiler can't take, are left as written; linting flags
the same rules.
Packages and versions
JSX compiles to react/jsx-runtime (or your tsconfig's jsxImportSource), pinned by compatible range: the codebase
runs one React 19.x, with the matching React DOM (packages).
Trying components out
A watch expression can render a component. tcm check shows an element as itself, and the studio's
notebook shows what it renders.
import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "#codebase/notes";
renderToStaticMarkup(<Markdown text="**hi**" />); // ⧩ '<div class="markdown"><p><strong>hi</strong></p></div>'
<Markdown text="**hi**" />; // ⧩ <Markdown />
The notes example is a complete app.
Routes, links and data: the react library
tcm lib add tcm react mounts tcm's React library read-only at <root>.lib.react, offline. It stays
at its version until tcm lib upgrade react (upgrading). Its README
(tcm view shop.lib.react.README) has the details.
// 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()path,query(URLSearchParams),hash,href; re-renders when they changenavigate(to, { replace?, scroll? })- go to an app path without reloading, then scroll to the top or fragment (not with
replace). Other sites load normally useNavigating()- whether a navigation is still rendering (show it after a short delay)
href(path)- an app path as a link address, under the app's base if it has one (deploying). For React Aria:
<RouterProvider navigate={navigate} useHref={href}> match(pattern, path)- a path's parameters (
/p/:id,/docs/*), or undefined useAppLinks(isApp?)- follow
<a href>links to the app without reloading, from any component. Links with a modifier key,target,downloadorrel="external", or turned down byisApp, are left to the browser useData(key, read, { keep? })- read once per key while the page is open (put what
readdepends on inkey; undefined reads nothing).keepshows the previous value (stale) while the next loads. Givesvalue,error,loading,refreshing,stale,refresh() <Pages routes={…} keep? notFound? />- the first route matching the path (
{ "/": Home, "/p/:id": Product }, each givenparamsandquery), elsenotFound. The lastkeeppages (3) stay mounted, hidden, in<Activity>, so going back keeps their state; a hovered link's page renders ahead refresh(prefix?)- re-read shown data whose key starts with
prefix. With none, server components render again too (on a timer, for live pages) useAppUpdate(){ ready, reload }: whether a PWA's new version is ready, and load it<Stylesheet css={…} precedence? />- CSS for the page (below)
match, href and <Stylesheet> run anywhere; the rest make a component render in the browser. useData reads in
the browser, so the first paint shows it loading. Under a base, paths are the app's (/ is the base). Importing the
old #tcm/react is refused with how to use the library instead.
Stylesheets
// 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" /></>;
}
<Stylesheet> puts a codebase .css file (files are values) in the page once, through
React's stylesheets. The build
serves it as its own file (/_app/assets/styles-<hash>.css, immutable), fetched early through Early Hints. Nothing
shows unstyled: the first paint, streamed boundaries and navigations wait for it.
If browser code only passes the file to <Stylesheet>, its text stays out of the JavaScript, so editing it changes
only the CSS file. precedence ("default") groups stylesheets; later groups win. Other CSS (a computed string), and
stylesheets only server components render, go into the page as a <style>.
Tailwind
/* shop.styles, a .css file */
@import "tailwindcss";
@theme { --color-brand: #7c3aed; }
<Stylesheet css={styles} />
<h1 className="p-4 text-brand md:text-2xl">Products</h1>
A stylesheet that imports tailwindcss is a Tailwind v4 stylesheet. The build compiles it
with the utilities the app's code uses, browser and server alike, found in its class names as Tailwind's scanner finds
them. Write each class in full: text-${size} isn't found. @theme, @apply, @utility, @custom-variant,
@source inline(…) and the rest of Tailwind's CSS work, with no config file. It's served like any stylesheet, named
by what it compiles to, so a change to the code that uses a new utility changes the file. This holds even when only
server components render it. tcm ships Tailwind; tcm upgrade tailwindcss@<range> picks another version.
CSS modules
/* card.module.css, saved as shop.card */
.card { padding: 1rem; animation: appear 0.2s; }
.title { @apply text-lg font-semibold; }
:global(.dark) .card { background: black; }
@keyframes appear { from { opacity: 0; } }
import { card } from "#codebase/shop"; // shop.card, a CSS module
export function Product({ name }: { name: string }) {
return <div className={card.card}><h2 className={card.title}>{name}</h2></div>;
}
A file named .module.css is a CSS module. Importing it gives its classes by name (card.title), typed, so a class
it doesn't have is a type error; one with a dash is card["my-class"]. Each class and @keyframes is renamed with
the start of the file's hash (title_k4nj7c), the same on the server, in the browser and in tests. :global(.x), or
what follows a bare :global in a selector, stays as written.
There's nothing to link. The app's pages link one stylesheet holding every module its code uses, server components'
included, so it's there before the first paint. A module can use Tailwind's @apply and @variant, with the app's
Tailwind stylesheet as its theme (or Tailwind's own, if it has none); a @reference of its own picks another.
Navigating is a transition
navigate, back and forward render in a transition: the current
page stays until the next is ready, rather than flashing <Suspense> fallbacks. Only what changes is fetched again,
so let list entries read the URL rather than passing the selection to the list:
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>;
}
View transitions
React's <ViewTransition> animates navigations. Keep
<Suspense> outside a <ViewTransition> keyed by the page:
<Suspense fallback={<p>loading…</p>}>
<ViewTransition key={noteId}> {/* the note out, the next one in: a cross-fade */}
<NotePreview id={noteId} />
</ViewTransition>
</Suspense>
Transition types are navigate, plus navigate-forward or navigate-back. Style by type with
enter={{ "navigate-back": "slide-right", default: "auto" }}.
cache and cacheSignal
Import them from react. On the server, cache keeps results for one request (a page, a server component's tree,
or a server function call), comparing arguments as React does, and cacheSignal() aborts when the browser goes away.
Elsewhere (the browser, tests) cache keeps nothing and 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>;
}
Contexts
A server component can provide a context, defined at the top level, to the browser components below it. Its value must be data that crosses.
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
}
A server component can't read a context, or see providers above it: pass what it needs as props.
Code splitting
A top-level lazy component that a server component renders loads in the browser. What only its import() reaches
gets its own chunks, loaded with the page's HTML or as soon as a tree names 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
}
Cookies
Server components can call setCookie, when fetched and in the first paint. Cookies set after the headers went out
(in a streamed boundary) reach the browser through a one-time token (POST /__tcm/cookies/<token>, within a minute),
so httpOnly cookies work too.