React Integration

Pretext + React: Hooks, SSR, Next.js & Production Patterns

Pretext has no React adapter in the official package, by design. The library is framework-agnostic and the author has been explicit that wrappers belong in the community. This page is a complete, production-ready guide to using Pretext in a React codebase: the hook patterns that hold up, the SSR and hydration considerations you have to handle yourself, font-loading timing, and integration with the virtual scroller libraries you're probably already using.

If you arrived from a search for "pretext react" and you want code you can drop into a project, scroll to the useTextLayout hook below. If you want the why before the what, the page is structured to give it to you.

Why Pretext Pairs Well With React

React's rendering model assumes that render() returns the same output for the same props — that components are pure functions of state. The DOM measurement APIs break this assumption: getBoundingClientRect() is impure, and reading from it during render is one of the canonical React anti-patterns that produces hard-to-debug behavior. The React-blessed escape hatches — useLayoutEffect, ResizeObserver — work, but they push measurement to the commit phase, where you've already paid the layout cost.

Pretext lets you measure during render, deterministically, without DOM access. Same input → same output. No commit-phase work. No imperative ref handling. The mental model collapses to "text dimensions are derived state, computed from text and font," which is how text dimensions should have worked all along.

The Core Hook: useTextLayout

Here's the one custom hook that covers ~80% of React integration use cases:

import { useMemo } from 'react';
import { prepare, layout } from '@chenglou/pretext';

export function useTextLayout(
  text: string,
  font: string,
  maxWidth: number,
  lineHeight: number = 1.4
) {
  const prepared = useMemo(
    () => prepare(text, font),
    [text, font]
  );

  return useMemo(
    () => layout(prepared, maxWidth, lineHeight),
    [prepared, maxWidth, lineHeight]
  );
}

That's it. prepare() is memoized on (text, font) because it's the expensive call; layout() is memoized on (prepared, maxWidth, lineHeight) because cheap calls are still worth not repeating. The return value is { height, lineCount }, which you can use immediately during render to size the container, position siblings, or feed into a virtual scroller.

Usage in a component:

function Message({ text, width }: { text: string; width: number }) {
  const { height, lineCount } = useTextLayout(text, "14px Inter", width);

  return (
    <div style={{ width, height, fontFamily: 'Inter', fontSize: 14 }}>
      {text}
      <span className="line-count">{lineCount} lines</span>
    </div>
  );
}

The container has its final height before React commits the DOM. No CLS, no layout shift, no skeleton-screen guessing.

A Variant for Per-Line Rendering

If you're rendering text yourself line by line — which is the case for canvas-based rendering, animations that target individual lines, or layouts that need per-line styling — switch to layoutWithLines:

import { useMemo } from 'react';
import { prepare, layoutWithLines } from '@chenglou/pretext';

export function useTextLines(
  text: string,
  font: string,
  maxWidth: number,
  lineHeight: number = 1.4
) {
  const prepared = useMemo(() => prepare(text, font), [text, font]);
  return useMemo(
    () => layoutWithLines(prepared, maxWidth, lineHeight),
    [prepared, maxWidth, lineHeight]
  );
}

lines in the return is an array of { text, width, start, end } objects. Render them however you like.

SSR and Hydration: The Honest Story

The first thing to know: prepare() requires a canvas, which doesn't exist in Node.js by default. This is not a bug; it's the design. Pretext's measurement pipeline gets its accuracy from the browser's actual font engine, and Node has no font engine.

Three patterns work. Pick based on your tolerance for setup complexity.

Pattern 1: Defer to client. Measure on the client only, accept that the first paint won't have correct dimensions. Use a small skeleton placeholder for the duration of one paint cycle. This is the easiest pattern and often the right call for non-critical content.

import { useEffect, useState } from 'react';
import { prepare, layout } from '@chenglou/pretext';

function ClientMeasured({ text, font, width }: Props) {
  const [dims, setDims] = useState<{ height: number; lineCount: number } | null>(null);

  useEffect(() => {
    const prepared = prepare(text, font);
    setDims(layout(prepared, width));
  }, [text, font, width]);

  if (!dims) return <div style={{ width, height: 24 }} />; // skeleton
  return <div style={{ width, height: dims.height }}>{text}</div>;
}

Pattern 2: Server-side via @napi-rs/canvas. Install @napi-rs/canvas (Node-side canvas with system font access) and Pretext's prepare() works in Node. This requires bundling discipline — most module systems will trip on the native binary unless you mark it as external. The payoff is correct dimensions on the server-rendered HTML, which means correct CLS metrics, correct hydration, and no skeleton flash.

Pattern 3: Pre-compute at build time. For static text — blog posts, marketing pages, anything in a CMS — compute layouts at build time, ship the dimensions as a JSON sidecar, and use them at render time. Pretext is fast enough that this fits comfortably into a Next.js getStaticProps or an Astro static build.

Font Loading: The Subtle Bug

Pretext measures using whatever font is loaded at the moment prepare() is called. If your fonts load asynchronously (web fonts, @font-face, dynamic imports), prepare() called before the font has loaded will return measurements based on the fallback font — and your layout will be wrong by tens of pixels.

The fix is to await document.fonts.ready before calling prepare(), or to listen for document.fonts.onloadingdone and clearCache() plus re-prepare anything that depends on the affected font.

A small wrapper:

let fontsReadyPromise: Promise<void> | null = null;
function ensureFontsReady() {
  if (!fontsReadyPromise) {
    fontsReadyPromise = document.fonts.ready.then(() => undefined);
  }
  return fontsReadyPromise;
}

export function useTextLayoutSafe(text: string, font: string, maxWidth: number) {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    ensureFontsReady().then(() => setReady(true));
  }, []);

  const prepared = useMemo(
    () => (ready ? prepare(text, font) : null),
    [text, font, ready]
  );
  return useMemo(
    () => (prepared ? layout(prepared, maxWidth) : null),
    [prepared, maxWidth]
  );
}

The cost: you get a one-paint flash where dimensions aren't ready. The benefit: you never get wrong dimensions silently.

Integration With Virtual Scrollers

Variable-height virtual scrolling is one of Pretext's strongest fits. Libraries like react-virtuoso, @tanstack/react-virtual, and virtua all take a per-row height function. Wire Pretext through that callback:

import { useVirtualizer } from '@tanstack/react-virtual';
import { prepare, layout } from '@chenglou/pretext';

function MessageList({ messages, width }: { messages: Message[]; width: number }) {
  // Prepare all messages once
  const prepared = useMemo(
    () => messages.map(m => prepare(m.text, "14px Inter")),
    [messages]
  );

  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (i) => layout(prepared[i], width).height,
    overscan: 8,
  });

  // ... render
}

The win: estimateSize returns the actual height, not an estimate, so the virtualizer doesn't need a measurement-correction pass. Scrolling is smooth, scrollbar position is stable, and there's no jank when rows enter the viewport.

Pretext + React Native

Pretext is browser-first. It uses canvas's measureText(), which doesn't exist in React Native. The library does not currently target React Native. There are early conversations in the issues about a measurement adapter for React Native that would use the platform's native text APIs as the measurement source while keeping the pure-arithmetic layout phase, but nothing has shipped at the time of writing.

If you need DOM-free text layout in React Native today, the closest equivalent is react-native-text-size, which gives you platform-accurate text measurement but uses native-bridge calls (so it's not as fast as Pretext's pure JavaScript path).

Next.js Specifics

The framework-specific pieces:

Common Gotchas

A short list of things that will trip you up:

  1. Font string format must match canvas exactly. "16px Georgia" works; "16px, Georgia" does not. Quote font names with spaces: "16px 'Helvetica Neue'".
  2. prepare() is per-font, not per-style. If you want to measure the same text in italic and roman, that's two different prepare() calls.
  3. Re-preparing on every render kills the win. useMemo is mandatory, not optional.
  4. maxWidth is in CSS pixels, not device pixels. No retina conversion needed.
  5. lineHeight is a multiplier, not a pixel value. 1.5 means "1.5× the font size," matching CSS unitless line-height.

What to Read Next

If you want to understand the library architecture, the How Pretext Works deep dive explains the two-phase pipeline. If you want a TypeScript-specific guide with type signatures, see the Pretext TypeScript page. If you want to compare measured Pretext performance against DOM measurement on your machine, the benchmark walkthrough has reproducible code.

The library is at github.com/chenglou/pretext. Try the live API at the Pretext Playground demo.


pretext.cool is a community-maintained showcase, not affiliated with Cheng Lou or the official Pretext project.

Related Pages

Try a Live Demo

← Browse all 20 demos