React and server components — tcm-ts

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 insideabecomes
browser codeserver componenta stub that fetches its rendered tree by hash (POST /__tcm/rpc/<hash>), its props checked against its parameter types
a server componentbrowser componenta reference to its hash, with its props
a server componentpackage's browser component ("use client", or hooks, contexts, classes: packages)a reference to the package's export; the server doesn't load the package
eithercomponent that renders anywhererendered 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.
  • location is the page's URL on the server, so a router renders the same on both sides. For the URL, use useLocation rather than useSyncExternalStore, 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 start sends 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 build says whether shells are kept (shells in tcm-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.

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 change
navigate(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, download or rel="external", or turned down by isApp, are left to the browser
useData(key, read, { keep? })
read once per key while the page is open (put what read depends on in key; undefined reads nothing). keep shows the previous value (stale) while the next loads. Gives value, error, loading, refreshing, stale, refresh()
<Pages routes={…} keep? notFound? />
the first route matching the path ({ "/": Home, "/p/:id": Product }, each given params and query), else notFound. The last keep pages (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.

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.

Built from c482a14e