The application · for app developers, especially anyone coming from Next.js or Remix · Markdown

111. Values the server and browser disagree on

The question this chapter answers: what happens to a component that reads the local clock, the time zone, Math.random() or window (values the server and the browser would compute differently) and why does it not end in a hydration error?

This chapter calls such a value a browser-only value: one whose right answer depends on the viewer's machine or the moment the browser runs, so a server cannot produce the string the browser will produce.

The problem in other frameworks

A component like this is common:

tsx
export default function Clock() {
  return <p>Rendered at {new Date().toLocaleTimeString()}</p>;
}

Next.js, Remix and React's own renderToString run that line in Node. The server writes its own time in its own zone, usually UTC in a container. The browser then runs the component again to hydrate it at a later moment in the viewer's zone and gets a different string. React 19 reports "Hydration failed because the server rendered text didn't match the client" and renders that part again in the browser; React 18 warned in development. Next.js lists this case among its common hydration errors and offers three fixes: move the read into useEffect, put suppressHydrationWarning on the element so the server's text stays until the next render or load the component with dynamic(..., { ssr: false }) so the server renders nothing for it.

Each fix is written by hand after the error shows up.

What fsr does

The build reads every component before anything runs, so it can tell a browser-only value from one the server can compute. It never renders a browser-only value on the server. What it does instead depends on where the value is used:

The componentWhere it runsWhat you see
Writes the viewer's clock as text: <p>{new Date().toLocaleTimeString()}</p>, a const holding it, a helper returning it, a prop a child writes as textThe server writes nothing for the value; the component hydrates with the value null, which matches, then computes it once mounted and renders againA browser row in the report naming the line; the markup around the value is the server's
Uses a browser-only value anywhere else: in an attribute, in a condition, choosing markup, Math.random() shaping a list, windowThe component renders in the browserThe report marks it client and names the line; the page around it is still server rendered
Calls console.log, info, debug, warn or errorThe server logs on every render; the browser logs again when the component hydratesA line under fsr::console in the server's log; the HTML is unchanged
Logs a browser-only valueThe browser aloneNo server line for that call; the component still renders on the server
Handles an event, onClick={...}The browserThe server writes the markup and the browser attaches the handler
Runs an effect, useEffect(...)The browserNothing on the server, as in React
Submits a form to a function, <form action={save}>The browserThe server writes React's own placeholder action, so hydration matches
Passes a setter or dispatch onThe browser calls itOn the server it is a function that renders nothing, so {dispatch ? "ready" : ""} renders the same on both

The first row is the useEffect fix Next's documentation has you write, written for you around the one expression that needs it and only after the build has checked the value lands as text or a prop rather than shaping markup. The second row does what Next's ssr: false does, for the one component that needs it. The rest are things React frameworks either run twice or leave you to arrange.

Logging

A console call in a component's body is written to the server's log on every render:

tsx
export default function Board({ squares }: { squares: string[] }) {
  const winner = calculateWinner(squares);
  console.log("board rendered, winner", winner);
  return <p>{winner ?? "no winner yet"}</p>;
}
text
INFO fsr::console: board rendered, winner null

The arguments are joined by spaces: a string as it is, a number or a boolean as JavaScript prints it, an object or an array as JSON. log and info are written at info, debug at debug, warn and error at their own levels. A call inside an if logs only when that branch runs. With no fibre_logging.yaml beside the configuration, the host writes warnings and errors plus everything under fsr::console from debug up to stderr, which is what fsr dev shows. A fibre_logging.yaml sets its own levels, so production can turn the target off.

A call that logs a browser-only value is left to the browser and the component still renders on the server:

tsx
export default function Greeting({ name }: { name: string }) {
  console.log("Greeting was rendered at", new Date().toLocaleTimeString());
  return <h3>Hello, {name}!</h3>;
}

The server writes <h3>Hello, Ada!</h3> and no log line. The browser logs Greeting was rendered at 14:02:11 in the viewer's time when the component hydrates. A component that never hydrates, one with no state and no handlers, never runs that call in the browser either, the same as a page Next renders statically.

One argument is still refused: a call to an extension only the server has, id.new() for one, since the browser would make the same call when the component hydrates and has no such extension.

The viewer's clock as text

A browser-only value written as text under an element is left to the browser and the component still renders on the server:

tsx
export default function Clock() {
  return <p className="at">Rendered at {new Date().toLocaleTimeString()}</p>;
}

The server writes <p class="at">Rendered at </p>. In the browser copy the build wraps the expression so it is null until the component has mounted: the first render matches the server's markup, so hydration has nothing to report and a second render right after mount computes the time and writes it. React's hydration never patches a text difference it is told to ignore, so this is the shape that works. The report says so:

text
browser   src/ui/Clock.tsx#Clock             src/ui/Clock.tsx:2:47: the current time on a render path, which the server and the browser read at different moments

The same holds for a const that holds the value, a helper that returns it and a prop a child writes as text: the parent computes it once mounted and the child renders again with it. The build only does this where the value is text or a prop, so the first render's markup is the server's. A browser-only value in an attribute, in a condition or choosing between markup sends the component to the browser instead:

text
rendered  src/ui/Clock.tsx#Clock             client      src/ui/Clock.tsx:2:34
client    src/ui/Clock.tsx:2:34              `.getHours()`, which reads the viewer's time zone

The page still renders on the server. Clock arrives as a React island with its props and draws itself in the browser, with no mismatch to report since the server sent no text for it. When the value does not have to be the viewer's, compute it on the server instead: ctx.now in the loader passed down as a prop, toISOString(), the getUTC* methods or intl.date(when, "long") from @snapfire/fsr-client/std under the request's locale. Then the component renders on the server and the browser computes the same string at hydration.

The lab

Open the lowering conformance app's Board.tsx and run fsr serve app from examples/conformance/lowering. Load /idioms: the server's log shows fsr::console: board rendered, winner null once per request and the browser console shows the same line once the board hydrates.

Change the log line to console.log("board rendered at", new Date().toLocaleTimeString()) and rebuild with fsr build --verbose app. The report still lists Board as lowered, the server's log has no line for it and the browser console prints the viewer's time.

Now render the time instead: <p className="status">{new Date().toLocaleTimeString()}</p> and rebuild the same way. The report still lists Board as lowered and adds a browser row for the line: the server writes the paragraph empty and the browser fills it once the board hydrates. Clock.tsx on the same page does this on purpose, with a currency and a weekday formatted on the server beside it. Make the value choose something instead, <p className={new Date().getHours() > 6 ? "day" : "night"}>: this app sets [build] strict = true, so the build refuses Board and names the line, since strict mode lets nothing fall back to the browser without saying so. Remove strict and rebuild: Board is client, the page around it is in the HTML and the board appears once the browser draws it. Put the line back.