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.

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 Problem | Traditional Fix | Pretext Fix |
|---|---|---|
| Text height unknown before render | useLayoutEffect + offsetHeight | useMemo + layout() |
| Virtual scroll height estimation | Lazy measurement + scroll corrections | Exact heights from layout() |
| Chat bubble wasted space | Accept max-width waste | Shrink-wrap via layout().width |
| Server-side text dimensions | Not possible | prepare() + layout() in RSC |
| Container resize re-measurement | ResizeObserver + DOM reads | ResizeObserver + 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 pretextimport { 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.