Pretext and React: Text Layout Without the Layout Thrashing

Apr 4, 2026

React and text measurement have always been at odds. React wants to render declaratively — you describe the UI, React figures out the DOM updates. But text measurement requires the DOM to already exist. You have to render first, measure second, then re-render with the measurements. This creates a class of problems that every React developer has encountered: layout shifts, flickers, double renders, and useLayoutEffect hacks.

Pretext eliminates this conflict entirely. Because it computes text dimensions in pure JavaScript — without touching the DOM — it fits naturally into React's declarative model. You measure before you render. No double passes. No layout thrashing.

The Problem: React's Render-Measure-Rerender Cycle

Here is the pattern every React developer has written at some point:

function Message({ text }) {
  const ref = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    if (ref.current) {
      setHeight(ref.current.offsetHeight);
    }
  }, [text]);

  return (
    <div ref={ref} style={{ opacity: height ? 1 : 0 }}>
      {text}
    </div>
  );
}

This code renders the text invisibly, measures it, then triggers a second render to show it. The useLayoutEffect runs synchronously after DOM mutation but before paint, so the user does not see the invisible frame — but the browser still does the work twice. The component renders, the browser lays out the DOM, React reads offsetHeight (forcing a synchronous reflow), React triggers a state update, the component renders again, the browser lays out the DOM again.

For a single message, this is fine. For a chat app with hundreds of messages, or a virtual scroll with thousands of items, it is a performance disaster.

The Solution: Measure Before Render

With Pretext, text dimensions are computed before the component renders. No refs. No effects. No second pass.

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

function Message({ text, maxWidth }) {
  const { width, height } = useMemo(() => {
    const prepared = prepare(text, { font: '15px/1.4 system-ui' });
    return layout(prepared, maxWidth);
  }, [text, maxWidth]);

  return (
    <div style={{ width, minHeight: height, padding: '8px 12px' }}>
      {text}
    </div>
  );
}

The useMemo call computes the exact width and height synchronously during render. By the time React commits to the DOM, the dimensions are already set. One render. One layout. Zero thrashing.

React components with Pretext text measurement — declarative layout without DOM queries

Pattern 1: Tight-Wrap Chat Bubbles

The most common Pretext + React pattern. Chat bubbles with max-width waste horizontal space on short messages. Pretext computes the tightest width that preserves the natural line count:

function ChatBubble({ message, isMine }) {
  const maxWidth = 320;
  const font = '15px/1.4 "Inter", system-ui';

  const { width, height } = useMemo(() => {
    const prepared = prepare(message, { font });
    return layout(prepared, maxWidth);
  }, [message]);

  return (
    <div
      className={`rounded-2xl px-3 py-2 ${
        isMine ? 'bg-blue-500 text-white ml-auto' : 'bg-gray-100'
      }`}
      style={{ maxWidth, width: width + 24 }}
    >
      {message}
    </div>
  );
}

The bubble wraps tightly around the text. Short messages get narrow bubbles. Long messages wrap naturally at maxWidth. The width is known before the first paint — no shift, no flicker.

Pattern 2: Virtual Scroll with Exact Heights

Virtual scroll libraries need to know each row's height to calculate scroll position. Without accurate heights, the scrollbar jumps when items are measured lazily. Pretext provides exact heights upfront:

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

function VirtualMessageList({ messages }) {
  const parentRef = useRef(null);
  const containerWidth = 600;
  const font = '14px/1.6 system-ui';
  const padding = 24; // vertical padding

  // Prepare all messages once
  const prepared = useMemo(
    () => messages.map(m => prepare(m.text, { font })),
    [messages]
  );

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (index) => {
      const { height } = layout(prepared[index], containerWidth);
      return height + padding;
    },
  });

  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(item => (
          <div
            key={item.key}
            style={{
              position: 'absolute',
              top: item.start,
              height: item.size,
              width: containerWidth,
            }}
          >
            {messages[item.index].text}
          </div>
        ))}
      </div>
    </div>
  );
}

Every row height is exact from the first render. The scrollbar is accurate. Scrolling is smooth. No lazy measurement, no height corrections, no scroll jumps.

Pattern 3: Responsive Text with useSyncExternalStore

When the container width changes — window resize, sidebar toggle, orientation change — you need to re-layout text. Pretext makes this cheap because layout() is pure arithmetic:

function ResponsiveArticle({ text }) {
  const containerRef = useRef(null);
  const [width, setWidth] = useState(0);

  useEffect(() => {
    const observer = new ResizeObserver(([entry]) => {
      setWidth(entry.contentRect.width);
    });
    if (containerRef.current) observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, []);

  const prepared = useMemo(
    () => prepare(text, { font: '16px/1.6 Georgia' }),
    [text]
  );

  const { height, lines } = useMemo(
    () => (width > 0 ? layout(prepared, width) : { height: 0, lines: 0 }),
    [prepared, width]
  );

  return (
    <div ref={containerRef}>
      <p style={{ minHeight: height }}>{text}</p>
      <span className="text-sm text-gray-500">{lines} lines</span>
    </div>
  );
}

The prepare() call runs once. Every resize only runs layout() — sub-millisecond, no DOM measurement. The component knows how many lines the text will occupy at any width, before rendering it.

Pattern 4: Server Components

React Server Components cannot use hooks, refs, or browser APIs. This has made text measurement impossible on the server — until Pretext.

Because prepare() and layout() are pure JavaScript (with a pluggable measurement function for server environments), you can compute text layout in a Server Component and pass the dimensions down as props:

// app/chat/page.tsx — Server Component
import { prepare, layout } from 'pretext';

export default async function ChatPage() {
  const messages = await db.getMessages();
  const font = '15px/1.4 system-ui';

  const messagesWithLayout = messages.map(m => {
    const prepared = prepare(m.text, { font });
    const { width, height } = layout(prepared, 320);
    return { ...m, width, height };
  });

  return <ChatList messages={messagesWithLayout} />;
}

// ChatList.tsx — Client Component
'use client';

function ChatList({ messages }) {
  return (
    <div className="flex flex-col gap-2">
      {messages.map(m => (
        <div
          key={m.id}
          style={{ width: m.width + 24, minHeight: m.height }}
          className="rounded-2xl bg-gray-100 px-3 py-2"
        >
          {m.text}
        </div>
      ))}
    </div>
  );
}

The client receives pre-computed dimensions. No hydration mismatch. No client-side measurement pass. The layout is correct on the very first frame.

Pattern 5: Animated Text Transitions

When text content changes — a counter incrementing, a status updating, a message being edited — the container size changes abruptly, causing jarring shifts. With Pretext, you can animate between the old and new dimensions:

import { motion } from 'framer-motion';

function AnimatedLabel({ text }) {
  const font = '14px/1.4 system-ui';

  const { width, height } = useMemo(() => {
    const prepared = prepare(text, { font });
    return layout(prepared, 200);
  }, [text]);

  return (
    <motion.div
      animate={{ width: width + 16, height: height + 8 }}
      transition={{ type: 'spring', stiffness: 300, damping: 30 }}
      className="overflow-hidden rounded-lg bg-gray-100 px-2 py-1"
    >
      {text}
    </motion.div>
  );
}

Because Pretext gives you the target dimensions instantly, the animation starts immediately — no "measure, then animate" delay.

Caching and Performance

Pretext's prepare() step is the expensive one — it measures glyphs and segments text. In React, you should memoize it:

// Good: prepare is memoized, layout is cheap
const prepared = useMemo(() => prepare(text, { font }), [text, font]);
const result = useMemo(() => layout(prepared, width), [prepared, width]);

// Bad: prepare runs on every render
const result = layout(prepare(text, { font }), width);

For lists of items, prepare all items once and store the results:

const preparedItems = useMemo(
  () => items.map(item => ({
    ...item,
    prepared: prepare(item.text, { font }),
  })),
  [items]
);

Now layout() calls during scroll or resize are effectively free — pure arithmetic over cached data.

When Not to Use Pretext in React

Pretext is not needed when:

  • Text is static and does not affect layout — if text height does not matter to your component, just render it normally
  • CSS handles it — if min-height, max-height, or intrinsic sizing solve your problem, use CSS
  • You have fewer than ~10 items — the render-measure-rerender cycle is negligible for small lists

Pretext shines when you have many items, dynamic content, size-dependent layout, or animation that requires knowing dimensions before rendering.

Summary

React ProblemTraditional FixPretext Fix
Text height unknown before renderuseLayoutEffect + offsetHeightuseMemo + layout()
Virtual scroll height estimationLazy measurement + scroll correctionsExact heights from layout()
Chat bubble wasted spaceAccept max-width wasteShrink-wrap via layout().width
Server-side text dimensionsNot possibleprepare() + layout() in RSC
Container resize re-measurementResizeObserver + DOM readsResizeObserver + layout() (no DOM reads)

Pretext turns text layout from a side effect into a computation. In React, that is exactly where it belongs — inside useMemo, not inside useEffect.

Getting Started

npm install pretext
import { prepare, layout } from 'pretext';

const prepared = prepare('Hello, React!', { font: '16px/1.5 sans-serif' });
const { width, height } = layout(prepared, 300);

Explore the interactive examples to see these patterns in action, and check the docs for the complete API reference.


Learn more at pretextjs.dev or explore the interactive demos.

Pretext.js Team

Pretext.js Team

Pretext and React: Text Layout Without the Layout Thrashing | Blog