skip to content

In a React Native poem reader, how would you show a 'Read more' toggle only when a stanza really exceeds three lines, using onTextLayout?

level: seniorimportance: should knowfreq 34%

answer

  1. you need the uncapped line count
  2. nativeEvent.lines, one entry per line
  3. measure a hidden, unconstrained copy
  4. same width and styles as visible Text
  5. re-measure on width or font change

basics

~20 s

Measure the stanza's full line count with onTextLayout on a hidden, uncapped copy of the Text at the same width and style. If nativeEvent.lines.length exceeds three, show the toggle and cap the visible Text with numberOfLines={expanded ? 0 : 3}.

solid answer

~40 s

`numberOfLines` truncates but never tells you whether it did, and character counts are useless because wrapping depends on width, font and the user's text size. `onTextLayout` does report layout: its `nativeEvent.lines` array has one entry per rendered line, each with `text`, `x`, `y`, `width`, `height`, `ascender`, `descender`, `capHeight` and `xHeight`. The decision needs the line count of the **full** text, so measure a copy that has no `numberOfLines`, placed absolutely in the same container so it gets the same width, with the same style, `opacity: 0` and `pointerEvents: 'none'`. When `lines.length > 3`, store `true` and render the toggle; the visible `Text` uses `numberOfLines={expanded ? 0 : 3}`. Only update state when the value changes, and let the copy re-measure when width or font size changes, for example on rotation.

code

tsx · 41 lines
tsx
import {useState} from 'react';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import type {TextLayoutEvent} from 'react-native';

const MAX_LINES = 3;

export function Stanza({body}: {body: string}) {
  const [expanded, setExpanded] = useState(false);
  const [overflows, setOverflows] = useState(false);

  const onMeasure = (e: TextLayoutEvent) => {
    const next = e.nativeEvent.lines.length > MAX_LINES;
    setOverflows(prev => (prev === next ? prev : next));
  };

  return (
    <View>
      <Text style={styles.body} numberOfLines={expanded ? 0 : MAX_LINES}>
        {body}
      </Text>
      {/* Hidden, uncapped copy with the same width and style. */}
      <Text
        style={[styles.body, styles.measure]}
        onTextLayout={onMeasure}
        pointerEvents="none">
        {body}
      </Text>
      {overflows ? (
        <Pressable onPress={() => setExpanded(v => !v)}>
          <Text style={styles.toggle}>{expanded ? 'Show less' : 'Read more'}</Text>
        </Pressable>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  body: {fontSize: 17, lineHeight: 26},
  measure: {position: 'absolute', left: 0, right: 0, opacity: 0},
  toggle: {fontWeight: 'bold', marginTop: 4},
});

go deeper

for a junior

Recall that onTextLayout exists on Text and reports an array of lines, and that numberOfLines with 0 removes the cap for an expanded view.

for a middle

Explain why the uncapped line count is what you need, what nativeEvent.lines contains, and why character counts fail across widths and font sizes.

for a senior

Build the hidden-copy pattern without flicker or render loops, keep copy and visible Text identical, and account for rotation, font scaling and cost in long lists.

for a principal

Decide whether per-row text measurement is worth its layout cost in a large feed, or whether fixed previews or server-side excerpts serve the product better.

## Why numberOfLines alone is not enough A poem reader shows each stanza collapsed to three lines with a **Read more** control. The control must appear only for stanzas that are actually longer than three lines; a two-line stanza with a useless "Read more" looks broken. `numberOfLines={3}` does the truncation, but it gives no signal about whether anything was cut. Counting characters or newlines does not work either, because the number of **laid-out** lines depends on: - the width the `Text` is given, which changes with device, orientation and split-screen; - the font family, size, weight and `lineHeight` of every span in the stanza; - the user's system text size setting, which scales fonts at runtime; - where the platform's line breaker decides to wrap each word. Only the text engine knows the answer, and `onTextLayout` is how it reports it. ## What onTextLayout reports `onTextLayout` is a `Text` prop called whenever the text is laid out. Its event carries `nativeEvent.lines`, an array with **one entry per rendered line**: | Field | Meaning | |---|---| | `text` | the characters on that line | | `x`, `y`, `width`, `height` | the line's box inside the `Text` | | `ascender`, `descender` | the line's ascender and descender heights | | `capHeight`, `xHeight` | height of capitals and of lowercase letters | For this problem, only `lines.length` matters: it is the number of lines the text occupies at the width it was given. ## Measure the full text, not the capped one The decision needs the line count the stanza **would** take without a cap. The dependable way to get it is to lay out text that has no cap: 1. Render the visible `Text` with `numberOfLines={expanded ? 0 : 3}`. 2. In the same container, render a **measuring copy**: the same string and style, **no** `numberOfLines`, positioned absolutely with `left: 0` and `right: 0` so it gets the same width, with `opacity: 0` and `pointerEvents: 'none'` so it is invisible and cannot be touched. 3. In the copy's `onTextLayout`, compute `lines.length > 3` and store it in state, **only if it differs** from the current value, to avoid needless re-renders. 4. Render the toggle when that flag is true. The simpler alternative, laying out the visible `Text` first without a cap and clamping it after the first measurement, works but shows the full stanza for a frame before it collapses, which reads as a flicker in a scrolling list. ## Keeping it correct in production - **Re-measure on change.** The copy re-lays out, and `onTextLayout` fires again, when the width changes (rotation, split-screen, a resized container) or the font size changes. Because the flag is derived from that event, it stays correct without extra code. - **Keep the copy identical.** Any difference in style, padding or width between copy and visible `Text` makes the count wrong. Share one style object between them. - **Mind long lists.** Each measuring copy doubles the text layout work for its row. In a long virtualized feed, consider measuring only rows that are rendered and caching the result per stanza and width. - **Hide the copy from assistive technology** so a screen reader does not read the stanza twice; the accessibility props that do that are a separate topic. - **Do not trust `lines` for other purposes without checking.** Each entry's `text` gives the characters per line, which is useful for debugging, but decisions should rest on the count from uncapped text. ## Testing the behaviour `onTextLayout` depends on a real text engine, so unit tests that render the component without native layout cannot tell a long stanza from a short one. Two practical approaches: - test the **decision logic** separately: a pure function from a line count and a limit to a flag, plus a component test that fires the layout event with a fake `lines` array; - check the **visual result** on devices or end-to-end tests with a known long and a known short stanza, at both a default and a large system text size. ## Summary of the pattern | Concern | Choice | |---|---| | Truncation | `numberOfLines={expanded ? 0 : 3}` on the visible `Text` | | Detection | `onTextLayout` on an uncapped, hidden copy | | State updates | set the flag only when it changes | | Robustness | shared style, same width, re-measured on layout change |

  • In React Native, why not decide on 'Read more' by counting the characters or newlines in the stanza string?
    The number of laid-out lines depends on the available width, the fonts and line height of every span, the user's system text size and where the platform breaks lines. A 120-character stanza can take two lines on a tablet and five on a small phone at a large text size. Only the text engine knows, which is why the decision comes from `onTextLayout`'s `lines` array.
  • In React Native, what goes wrong if the onTextLayout handler calls setState with a new value on every layout?
    Every state update re-renders the component; if the handler always sets state, even to an equal but new value, each layout can schedule another render and the stanza does needless work in a scrolling list. Compare with the current value and only update when the overflow flag actually changes, as a functional update that returns the previous value does.

saying these in an interview costs you the question

  • numberOfLines exposes whether the text was truncated, so no measurement is needed.
  • Counting characters is a reliable way to predict how many lines a stanza takes.
  • Measuring once on mount is enough; line count never changes afterwards.
  • The measuring copy can use a different width or style as long as the text is the same.
  • onTextLayout returns a single height value, not per-line data.