In a React Native poem reader, how would you show a 'Read more' toggle only when a stanza really exceeds three lines, using onTextLayout?
answer
- you need the uncapped line count
- nativeEvent.lines, one entry per line
- measure a hidden, unconstrained copy
- same width and styles as visible Text
- re-measure on width or font change
basics
~20 sMeasure 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 linesimport {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
Recall that onTextLayout exists on Text and reports an array of lines, and that numberOfLines with 0 removes the cap for an expanded view.
Explain why the uncapped line count is what you need, what nativeEvent.lines contains, and why character counts fail across widths and font sizes.
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.
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.