Engineering

Why HTML Tables Break on React Native (And How We Fixed It)

Jan 18, 202611 min read
react-nativetableslayout

The Problem

HTML tables make an assumption: a layout engine exists that will calculate column widths automatically. CSS tables measure all cells, determine the longest content in each column, distribute remaining space, and render everything in one pass. The browser handles this transparently.

React Native has no such engine.

When you feed an HTML table to react-native-render-html, each cell renders independently. There is no coordinated pass where columns agree on widths. The result is predictable: columns collapse, text overflows, rows misalign. A table that looks correct in Safari becomes unreadable on iOS.

We needed tables to work. MDN documentation contains hundreds of them. Browser compatibility tables, API reference tables, method signature tables. Skip tables and you skip a significant chunk of the documentation.

Why Not Simpler Solutions?

WebView would have worked. Embed a browser, let it handle tables. But the app is called DocNative for a reason. WebViews drag in scroll conflicts, memory bloat, and that unmistakable feel of a webpage stuffed inside a shell.

The react-native-render-html library ships a heuristic table plugin. It guesses column widths. Simple tables render fine. MDN's 30-column browser compatibility grids do not. Python method signatures with nested code blocks do not. Columns collapsed to nothing. Rows overflowed without scrolling. Heuristics fail when the input is hostile. Documentation tables are hostile.

Why It Happens

React Native uses Yoga, a Flexbox implementation. Yoga understands flex containers, flex items, and percentage widths. It does not understand <table>, <tr>, or <td>. When react-native-render-html encounters a table, it maps these elements to Views with flex properties. Each cell becomes a flex item.

The mapping fails because HTML table layout is not Flexbox. Table layout has two passes: constraint collection and width distribution. Flexbox has one pass: children size themselves, parents adjust. A cell with width: auto in HTML will expand to fit content and then negotiate with siblings. A flex item with no explicit width will compress or expand based on flexGrow and flexShrink, ignoring content entirely.

The specific failures:

  1. Column width disagreement. Row 1 has a short cell; Row 2 has a long cell. Without coordination, each row picks different widths. Columns zigzag.
  2. Long words overflow. German compound nouns, API method names, URLs. These exceed the screen width and clip without wrapping.
  3. Text wrapping breaks mid-word. The system picks arbitrary break points, producing "funct-" on one line and "ion" on the next.
  4. Bold headers compress. Headers use font-weight: 600, which renders wider than regular text. Width calculations that ignore this produce truncated headers.

Phase 1: Estimation

Our solution begins before any cell renders. We extract the table structure from the parsed HTML and estimate column constraints by analyzing text content before measurement or rendering.

The estimation uses character width coefficients:

Content TypeCoefficientRationale
Regular text0.55Proportional fonts average 55% of font size per character
Bold header0.7536% wider due to font-weight: 600
Monospace0.65Fixed-width characters are wider than proportional

For a header cell containing "Parameters" at 16px font size, the estimated width is:

typescript
const charWidth = 16 * 0.75;  // 12px per character
const naturalWidth = 10 * 12;  // 120px for 10 characters

This runs for every cell in the table. We collect two values per column: minWidth (the longest unbreakable word) and naturalWidth (the longest line if given infinite space).

The minimum width calculation respects soft hyphens. These Unicode characters (\u00AD) mark valid break points inserted at build time. A word like documentation might be stored as docu-men-ta-tion, allowing the renderer to break at syllable boundaries. We insert soft-hyphens into content at build time in our documentation cleaning pipeline.

typescriptcomputeWidths.ts
const syllables = word.split('\u00AD');
const maxBreaks = word.length >= 16 ? 2 : 1;
const targetSegments = maxBreaks + 1;
const minSegmentLen = Math.ceil(word.length / targetSegments);

Short words get one break maximum. Long words (16+ characters) get two. This prevents excessive fragmentation while still allowing wrapping where it helps.

The Overflow Decision

After estimation, we know the total minimum width required: the sum of every column's minWidth. Compare this to the available screen width (device width minus 64px padding).

If the content fits, we use Flexbox. Each column gets flex: [calculatedWidth], distributing space proportionally. The table expands to fill the container.

If the content exceeds available width, we switch strategies. Each column gets an explicit width value, and the table sits inside a horizontal ScrollView. Flexbox does not work correctly inside scroll containers; explicit widths do.

typescriptCellRenderer.tsx
const cellStyle = {
  ...(needsScroll
    ? { width }      // Explicit width for scroll
    : { flex: width } // Proportional flex when fitting
  ),
};

This decision happens once per table, before rendering begins. Cells never switch strategies mid-render.

Width Distribution

When content fits, we distribute extra space intelligently. The algorithm:

  1. Start with each column at its minimum width.
  2. Calculate how much extra space each column wants (natural width minus minimum width).
  3. Distribute available extra space proportionally to demand.
  4. Cap each column at its natural width. No column grows beyond what its content requires.
  5. If any column hits its cap, redistribute its unused allocation to columns still below their caps.

This iterative redistribution continues until either all extra space is allocated or no column can accept more.

Tip

The iteration has a 1px threshold. If less than 1px remains to distribute, the loop exits. This prevents floating-point precision issues from causing infinite loops.

For scrollable tables, the logic differs. We distribute width proportionally to total content volume (the sum of all naturalWidth values in each column). A column containing dense code examples gets more space than a column of short labels.

Colspan Handling

Tables with merged cells (colspan) complicate everything. A cell spanning three columns does not tell us anything about individual column widths. It only constrains the sum.

Our approach: distribute colspan constraints only when necessary. If a spanning cell requires more width than the covered columns already provide, we distribute the excess proportionally based on each column's flex weight.

typescriptcomputeWidths.ts
if (cellMinWidth > existingMinTotal) {
  const excess = cellMinWidth - existingMinTotal;
  for (let i = 0; i < colspan; i++) {
    const col = constraints[startColumn + i];
    const share = excess * (col.flexWeight / totalWeight);
    col.minWidth += share;
  }
}

If the spanned columns already satisfy the requirement, we leave them alone. This prevents colspans from inflating widths unnecessarily.

Embedded Content

Tables contain more than text. Code blocks, images, SVG diagrams. These elements have their own width requirements that character counting cannot predict.

We handle this with a callback system. Embedded renderers (CodeRenderer, ImageRenderer, SVGRenderer) report their measured dimensions after mounting. The table system receives these reports and updates column constraints accordingly.

A complication: embedded content renders after the initial layout pass. The table might finalize widths before an embedded code block reports its true size. We handle this with re-measurement.

When an embedded element reports a size more than 10% different from the estimate, the table triggers a new measurement cycle. This recomputes widths and re-renders cells with updated values.

Iteration Cap

Re-measurement is capped at 3 iterations. Some content produces unstable measurements (an image that resizes based on container width, for example). The cap prevents infinite re-render loops.

Pre-Population

Performance matters. Users scroll through documentation quickly. Tables need to render fast.

The naive approach: render cells, let each register itself, then compute widths. This creates a visible flash: cells appear at wrong widths, then jump to correct positions.

We eliminated the flash with pre-population. Before any cell component mounts, we analyze the parsed table structure and register all cells synchronously. Width computation happens before the first render pass.

typescriptTableMeasurementContext.tsx
useEffect(() => {
  if (phase !== 'estimating' || hasPrePopulated) return;

  const allRows = [...(tableStructure.thead ?? []), ...tableStructure.tbody];
  for (const row of allRows) {
    for (const cell of row.cells) {
      const estimates = estimateConstraints(/* ... */);
      cellsRef.current.set(key, { ...estimates });
    }
  }
  hasPrePopulated.current = true;
}, [tableStructure, phase]);

Cell components still register when they mount, but they find their data already populated. No delays, no second passes.

Viewport Adaptation

Phone screens vary. A table comfortable on iPad becomes cramped on iPhone SE. We adapt minimum column widths to viewport size:

BreakpointWidthMin Column
Phone portrait< 400px60px
Phone landscape< 600px80px
Tablet portrait< 900px100px
Tablet landscape≥ 900px120px

Small screens get aggressive minimums. Columns compress further before triggering horizontal scroll. Large screens get relaxed minimums. Content breathes.

Typography scales too. Phone portrait uses 90% font size with 6px cell padding. Tablets use full size with 10px padding. These adjustments happen automatically based on device characteristics detected at render time.

Results

The system handles MDN's browser compatibility tables, which contain 30+ columns of icons and version numbers. It handles Python's method signature tables, with long parameter names and type annotations. It handles tables inside tables (yes, documentation does this).

Tables load without layout flashes. Scrollable tables indicate their scrollability with natural momentum. Soft hyphens break long words at sensible syllable boundaries.

The code handles edge cases we did not anticipate: tables with empty rows (filtered out), tables with empty columns (filtered out), single-cell tables (unwrapped entirely), tables with only headers (rendered normally).

The key insight: React Native forces you to solve problems that browsers solve invisibly. There is no layout engine to lean on. You measure, compute, and render with full knowledge of what you are doing. The result is a table renderer that works the way tables should work, rebuilt from first principles for a platform that has none.

What's Next

This is the rendering engine behind DocNative. When you read the Array.prototype.map documentation on your phone, the parameter tables render through this measurement system. The Rust standard library tables with their function signatures. The CSS property tables with browser support.

DocNative ships with docsets including MDN, Python, React, TypeScript, and Go. Every table in every docset passes through this pipeline. The documentation loads offline, scrolls at 60fps, and renders tables that actually look like tables. No network required. No browser engine required. Just the documentation you need, formatted correctly.

Read Docs Anywhere

Download DocNative and access documentation offline on iOS and Android.

Download iOS