skip to content

In a React Native 0.87 note-taking app, how do you position a tooltip under its anchor before the first frame is shown, without a flicker?

level: seniorimportance: should knowfreq 40%

answer

  1. layout lives in the shadow tree
  2. measure before the frame
  3. getBoundingClientRect on a host ref
  4. setState inside useLayoutEffect
  5. onLayout arrives too late

basics

~20 s

Measure the anchor's host View ref inside useLayoutEffect with getBoundingClientRect or measureInWindow, then set the tooltip's placement there. Fabric answers synchronously from the committed shadow tree, so the corrected position commits before the first frame.

solid answer

~40 s

Put a ref on the anchor's host `View` (typed `ViewInstance` in 0.87), and in `useLayoutEffect` read `ref.current?.getBoundingClientRect()` or call `measureInWindow`. On the New Architecture those reads come straight from the C++ shadow tree that React has just committed and Yoga has laid out; even the callback-style methods invoke their callback before returning. Setting state inside the layout effect makes React re-render synchronously, so the tooltip's final placement commits before the frame is shown, with no flicker. Render the tooltip only once its placement is known, keep the effect cheap because it delays the frame, and re-measure when inputs such as window size change. `onLayout` or `useEffect` would deliver the position after the first frame, which is the old flicker.

code

tsx · 39 lines
tsx
import { useLayoutEffect, useRef, useState } from 'react';
import {
  StyleSheet,
  Text,
  View,
  useWindowDimensions,
  type ViewInstance,
} from 'react-native';

const TOOLTIP_HEIGHT = 80;

export function TagWithTooltip({ label, hint }: { label: string; hint: string }) {
  const anchorRef = useRef<ViewInstance>(null);
  const { height: windowHeight } = useWindowDimensions();
  const [placement, setPlacement] = useState<'above' | 'below' | null>(null);

  useLayoutEffect(() => {
    const rect = anchorRef.current?.getBoundingClientRect();
    if (rect == null) return;
    const spaceBelow = windowHeight - (rect.y + rect.height);
    setPlacement(spaceBelow < TOOLTIP_HEIGHT ? 'above' : 'below');
  }, [windowHeight, label]);

  return (
    <View ref={anchorRef}>
      <Text>{label}</Text>
      {placement != null && (
        <View style={placement === 'above' ? styles.above : styles.below}>
          <Text>{hint}</Text>
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  above: { position: 'absolute', left: 0, bottom: '100%' },
  below: { position: 'absolute', left: 0, top: '100%' },
});

go deeper

for a junior

Remember that useLayoutEffect is where to measure a view before it is shown, and that the ref must point at a View.

for a middle

Explain why Fabric can answer layout reads synchronously from the shadow tree, and why setState inside the layout effect lands in the same frame.

for a senior

Pick between getBoundingClientRect, measureInWindow, onLayout and useEffect by their timing, keep layout effects cheap, and handle re-measurement on input changes.

for a principal

Decide where overlay positioning belongs in a design system so every tooltip and popover uses one measured, same-frame path.

## The scenario A note-taking app shows a small tooltip next to a tag chip ("Tap to filter by this tag"). The tooltip should sit **below** the chip, unless the chip is near the bottom of the screen, in which case it should sit **above**. The decision needs the chip's position, and the tooltip must never appear in the wrong place, even for one frame. ## Why this used to flicker In React Native's legacy architecture there was no synchronous way to read layout: 1. The chip mounted without a tooltip placement. 2. Its `onLayout` event, or a `measureInWindow` call in response to it, delivered the position **asynchronously**. 3. Setting state from that callback triggered another render and another mount. So there was at least one frame where the tooltip was missing or in the wrong place, and when the chip moved, the tooltip visibly trailed it. ## Why Fabric fixes it The Fabric renderer keeps layout in its C++ **shadow tree**. Once React commits, Yoga has already computed every node's size and position there, and the measuring methods on a host component's ref read that data directly: | Method | Returns | Since | |---|---|---| | `getBoundingClientRect()` | a `DOMRect` (`x`, `y`, `width`, `height`) in viewport coordinates | 0.82, with DOM-like host nodes | | `measureInWindow(callback)` | `x`, `y`, `width`, `height` in the window | legacy API | | `measure(callback)` | size plus `pageX`/`pageY` | legacy API | On the New Architecture the callback-style methods keep their callback shape, but the renderer's implementation invokes the callback **before the call returns**, using the current committed shadow tree. The documentation still describes it as asynchronous; the source is synchronous. `useLayoutEffect` runs after React commits and before the frame is shown. Reading layout there and setting state makes React re-render synchronously, so the corrected tree is committed in the **same frame**. ## The implementation ```tsx const anchorRef = useRef<ViewInstance>(null); useLayoutEffect(() => { const rect = anchorRef.current?.getBoundingClientRect(); if (rect == null) return; const spaceBelow = windowHeight - (rect.y + rect.height); setPlacement(spaceBelow < TOOLTIP_HEIGHT ? 'above' : 'below'); }, [windowHeight]); ``` Rules that make it work: - **Put the ref on a host component.** Measuring methods exist on native-backed components such as `View`, not on your own composite components; forward the ref to the underlying `View`. - **Don't render the tooltip until placement is known.** With `placement` initially `null`, the first commit contains no tooltip and the second, synchronous commit adds it in the right spot. - **Mind the coordinate space.** `getBoundingClientRect` returns viewport coordinates, so compare them with the window's size, as above; to place a tooltip relative to a container instead, measure against that container with `measureLayout`. - **Keep the effect cheap.** It runs synchronously before the frame; a slow layout effect delays the frame itself. - **Re-measure when inputs change**, such as window size or the chip's label, by listing them as dependencies. ## Choosing between the options - `useLayoutEffect` with `getBoundingClientRect` or `measureInWindow`: correct first frame; the standard answer on the New Architecture. - `onLayout`: still useful for reacting to size changes over time, but it is an event delivered after mounting, so a position derived from it lands in a later render. - `useEffect` with a measurement: runs after the frame can be shown, so the first frame can be wrong, the same flicker as before. ## Version notes - `useLayoutEffect` has been fully supported since the New Architecture, the default since React Native 0.76 and the only architecture since 0.82. - `getBoundingClientRect` on host refs arrived with the DOM Node APIs in 0.82. - The `ViewInstance` ref type is part of 0.87's Strict TypeScript API; on 0.86, which Expo SDK 57 ships, type the ref the older way. The interview answer in one line: **measure the anchor's host ref in `useLayoutEffect`, because Fabric reads layout synchronously from the committed shadow tree, and set state there so the corrected position commits before the first paint.**

  • In React Native, why does measuring a custom component's ref fail while measuring a View's ref works?
    Measuring methods such as measure, measureInWindow and getBoundingClientRect exist on host components, the ones backed by a native view and a shadow node, like View. A custom composite component has no shadow node of its own, so its ref has nothing to measure. Forward the ref to the underlying View, or wrap the component in a View and measure that.
  • In React Native 0.87, is measureInWindow's callback asynchronous inside useLayoutEffect?
    Not in practice on the New Architecture. The API keeps its callback shape, and the documentation still calls it asynchronous, but the renderer's implementation reads the current committed shadow tree and invokes the callback before measureInWindow returns. That is why it works for same-frame positioning, and why getBoundingClientRect, which simply returns the rectangle, reads more naturally.

saying these in an interview costs you the question

  • onLayout gives the position before the first frame is shown.
  • Measuring in useEffect avoids the flicker just as well.
  • Any component's ref, including a custom component's, can be measured.
  • Layout can only be read after the native view has been drawn.
  • A slow useLayoutEffect has no effect on when the frame appears.