TIL

Floating UI - useFloating

文章發表於
...

Introduction

This is the third article in my Floating UI deep dive series. If you haven't already, I recommend reading the previous articles first:

  1. - Understanding browser coordinates, placements, axes, and computeCoordsFromPlacement
  2. - The middleware pipeline: offset, shift, flip, and detectOverflow

In those articles, we explored how Floating UI calculates positions and adjusts them through middleware. Now we'll see how the React adapter, useFloating, brings it all together.

At the core of Floating UI lies computePosition - an imperative, async function that orchestrates everything we learned: it calculates initial coordinates using computeCoordsFromPlacement, then runs them through the middleware pipeline (offsetshiftflip → etc.) to produce final positioning data.

The useFloating hook is the React adapter that makes this imperative API work seamlessly with React's declarative paradigm. Understanding how it works reveals elegant solutions to common React challenges.

The Core Challenge

Here's the fundamental tension useFloating must resolve:

// computePosition is IMPERATIVE and ASYNC
computePosition(referenceEl, floatingEl, config).then(data => {
// Returns { x, y, placement, middlewareData, ... }
});
// But React is DECLARATIVE and SYNCHRONOUS
const [data, setData] = useState({ x: 0, y: 0 });
return <div style={{ transform: `translate(${data.x}px, ${data.y}px)` }} />;

The hook needs to bridge these two worlds while handling:

  • Element references - Both the trigger (reference) and floating element need to be tracked
  • Async updates - Position calculations happen asynchronously
  • Performance - Updates must be smooth during scroll/resize
  • Lifecycle - Proper cleanup when elements unmount

Pattern 1: Dual State + Ref

The Problem with State or Ref Alone

React has two ways to store values, each with trade-offs:

useStateuseRef
BehaviorTriggers re-render on changeDoes NOT trigger re-render
AccessValue captured in closures (stale state risk)Always current via .current
Best ForData that drives UIMutable values, DOM references

Using only refs means no re-renders:

// Problem: Ref changes don't trigger re-renders
const referenceRef = useRef(null);
useLayoutEffect(() => {
if (referenceRef.current && floatingRef.current) {
update(); // When does this re-run? Never automatically!
}
}, []); // Can't depend on ref.current - React won't track it

Using only state risks stale closures:

// Problem: State captured in closures becomes stale
const [reference, setReference] = useState(null);
const update = useCallback(() => {
// `reference` is captured when useCallback was created
// If reference changes, this callback still has the OLD value!
computePosition(reference, floating, config);
}, [reference]); // Must add dependency, recreating callback every time

The Solution: Use Both

useFloating stores elements in both state and refs:

const [_reference, _setReference] = React.useState<RT | null>(null);
const referenceRef = React.useRef<RT | null>(null);
const setReference = React.useCallback((node: RT | null) => {
if (node !== referenceRef.current) {
referenceRef.current = node; // Immediate access for computePosition
_setReference(node); // Triggers re-render to run effects
}
}, []);

This gives us:

  • State triggers useLayoutEffect when elements change
  • Ref provides always-current value for async callbacks

Pattern 2: useLatestRef for Fresh Values

Another stale closure problem: the update callback needs current values for platform, open, and whileElementsMounted, but adding them as dependencies would recreate the callback constantly.

function useLatestRef<T>(value: T) {
const ref = useRef(value);
useLayoutEffect(() => {
ref.current = value;
});
return ref;
}

This pattern keeps a ref synchronized with the latest prop value:

const platformRef = useLatestRef(platform);
const openRef = useLatestRef(open);
const update = useCallback(() => {
// Always gets the CURRENT value, even though it's not in deps
if (platformRef.current) {
config.platform = platformRef.current;
}
isPositioned: openRef.current !== false,
}, []); // Stable callback - no dependencies needed for these values!

The Update Flow

Timeline of a Typical Interaction

User clicks button that should show tooltip
setReference(buttonEl)
├──► referenceRef.current = buttonEl (SYNC - immediate)
└──► _setReference(buttonEl) (ASYNC - schedules re-render)
React re-renders
useLayoutEffect runs (before browser paint!)
update() called
computePosition(referenceRef.current, ...)
│ ▲
│ │
└──── Uses ref, not state (always current)
setData({x, y, ...})
Browser paints tooltip at correct position

The update Function

const update = React.useCallback(() => {
// Guard: Both elements must exist
if (!referenceRef.current || !floatingRef.current) {
return;
}
const config: ComputePositionConfig = {
placement,
strategy,
middleware: latestMiddleware,
};
if (platformRef.current) {
config.platform = platformRef.current;
}
computePosition(referenceRef.current, floatingRef.current, config).then(
(data) => {
const fullData = {
...data,
isPositioned: openRef.current !== false,
};
// Guard: Component might have unmounted during async operation
// Guard: Skip if data hasn't actually changed (prevents loops)
if (isMountedRef.current && !deepEqual(dataRef.current, fullData)) {
dataRef.current = fullData;
ReactDOM.flushSync(() => {
setData(fullData);
});
}
},
);
}, [latestMiddleware, placement, strategy, platformRef, openRef]);

Why flushSync?

React normally batches state updates for performance. But for positioning, we need immediate updates to prevent visual lag.

flushSync vs Batched Updates

Click "Start Animation" to see a reference element move automatically. Watch how the tooltip follows - the batched version lags behind and jumps, while flushSync keeps up smoothly.

Key insight: flushSync renders MORE often (see render count), but each render shows the CURRENT position. Batching renders less often, but skips intermediate positions causing visual lag.

Safety Guards in update()

The update function has three layers of protection:

// Guard 1: Elements must exist
if (!referenceRef.current || !floatingRef.current) {
return;
}
// Guard 2: Component must still be mounted (checked after async)
if (isMountedRef.current && ...) {
// Safe to update
}
// Guard 3: Data must have changed (prevents infinite loops)
if (!deepEqual(dataRef.current, fullData)) {
// Worth updating
}

The mounted check is crucial for async operations:

Component mounts
update() called
computePosition() starts (ASYNC!)
│ ──── User navigates away ────
│ │
│ ▼
│ Component unmounts
│ isMountedRef.current = false
Promise resolves
Check: isMountedRef.current?
├── false: Return early (avoid setState on unmounted)
└── true: Safe to setData()

The isPositioned Flag

isPositioned answers: "Has the floating element been placed at least once?"

This is critical for animations:

// Common pattern: Fade in AFTER positioned
<div
style={{
...floatingStyles,
opacity: isPositioned ? 1 : 0,
transition: isPositioned ? 'opacity 0.2s' : 'none',
}}
>
Tooltip content
</div>

Without isPositioned:

Initial render: x=0, y=0 (default)
Tooltip appears at (0,0) - TOP LEFT CORNER!
computePosition resolves
Tooltip JUMPS to correct position

With isPositioned:

Initial render: x=0, y=0, isPositioned=false
Tooltip invisible (opacity: 0)
computePosition resolves, isPositioned=true
Tooltip fades in at correct position

Connection to the open Prop

// When computing position
isPositioned: openRef.current !== false,
// When open changes to false
useLayoutEffect(() => {
if (open === false && dataRef.current.isPositioned) {
dataRef.current.isPositioned = false;
setData((data) => ({...data, isPositioned: false}));
}
}, [open]);

This resets isPositioned when the floating element closes, so it can animate in again on the next open.

whileElementsMounted: The Subscription Lifecycle

The Problem

computePosition gives you position at ONE moment. But elements move when:

  • User scrolls
  • Window resizes
  • Content changes size
  • Layout shifts occur

The Solution

whileElementsMounted is a callback that sets up continuous updates:

import { autoUpdate } from '@floating-ui/dom';
const { refs } = useFloating({
whileElementsMounted: autoUpdate,
// or with options:
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
ancestorScroll: true,
ancestorResize: true,
elementResize: true,
layoutShift: true,
animationFrame: false,
});
},
});

Lifecycle Visualization

┌─────────────────────────────────────────────────────────────────┐
│ Phase 1: Elements Mount │
└─────────────────────────────────────────────────────────────────┘
setReference(btn) ────┤
setFloating(tooltip) ─┘
┌─────────────────────────────────────────────────────────────────┐
│ Phase 2: Effect Runs │
│ │
│ if (referenceEl && floatingEl) { │
│ return whileElementsMounted(ref, float, update); │
│ } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Phase 3: autoUpdate Sets Up Listeners │
│ │
│ - Scroll listeners on ancestor elements │
│ - Resize listeners on window │
│ - ResizeObserver on both elements │
│ - IntersectionObserver for layout shift detection │
│ │
│ Returns cleanup function │
└─────────────────────────────────────────────────────────────────┘
User scrolls ────────┼───► update() called
Window resizes ──────┼───► update() called
Content changes ─────┼───► update() called
┌─────────────────────────────────────────────────────────────────┐
│ Phase 4: Cleanup (unmount or elements change) │
│ │
│ Effect cleanup runs: │
│ - Remove scroll listeners │
│ - Remove resize listeners │
│ - Disconnect observers │
└─────────────────────────────────────────────────────────────────┘

Why Use a Ref for whileElementsMounted?

const whileElementsMountedRef = useLatestRef(whileElementsMounted);

If we used the prop directly in the effect dependencies:

// Problem: Effect re-runs on every render if user passes inline function
useLayoutEffect(() => {
if (referenceEl && floatingEl && whileElementsMounted) {
return whileElementsMounted(referenceEl, floatingEl, update);
}
}, [referenceEl, floatingEl, whileElementsMounted]); // Unstable!
// User's code:
useFloating({
whileElementsMounted: (ref, float, update) => autoUpdate(ref, float, update),
// ↑ New function every render = effect re-runs constantly!
});

With the ref pattern:

const whileElementsMountedRef = useLatestRef(whileElementsMounted);
useLayoutEffect(() => {
if (referenceEl && floatingEl && whileElementsMountedRef.current) {
return whileElementsMountedRef.current(referenceEl, floatingEl, update);
}
}, [referenceEl, floatingEl]); // Stable deps! Ref not in dependency array.

floatingStyles: The CSS Output

const floatingStyles = useMemo(() => {
const initialStyles = {
position: strategy,
left: 0,
top: 0,
};
if (!elements.floating) {
return initialStyles;
}
const x = roundByDPR(elements.floating, data.x);
const y = roundByDPR(elements.floating, data.y);
if (transform) {
return {
...initialStyles,
transform: `translate(${x}px, ${y}px)`,
...(getDPR(elements.floating) >= 1.5 && {willChange: 'transform'}),
};
}
return {
position: strategy,
left: x,
top: y,
};
}, [strategy, transform, elements.floating, data.x, data.y]);

Transform vs Layout Positioning

AspecttransformLayout (left/top)
PerformanceGPU acceleratedTriggers layout recalc
Subpixel renderSmoothCan be blurry
AnimationSmoothCan be janky
Stacking contextCreates new oneDoesn't

Transform (default):

.floating {
position: absolute;
left: 0;
top: 0;
transform: translate(150px, 200px);
will-change: transform; /* Only on high DPR screens */
}

Layout positioning (transform: false):

.floating {
position: absolute;
left: 150px;
top: 200px;
}

DPR (Device Pixel Ratio) Handling

function getDPR(element: Element): number {
if (typeof window === 'undefined') return 1;
const win = element.ownerDocument.defaultView || window;
return win.devicePixelRatio || 1;
}
function roundByDPR(element: Element, value: number) {
const dpr = getDPR(element);
return Math.round(value * dpr) / dpr;
}

Why round by DPR?

On high-DPI screens, CSS pixels map to multiple physical pixels. Positioning at fractional CSS pixels can cause the element to straddle physical pixel boundaries, causing blurriness.

DPR = 2 (each CSS pixel = 2x2 physical pixels)
Without rounding (value = 150.3):
┌──┬──┬──┬──┬──┐
│ │ │▒▒│ │ │ ← Partial pixel coverage = BLUR
└──┴──┴──┴──┴──┘
With rounding (snaps to 150.5):
┌──┬──┬──┬──┬──┐
│ │ │██│ │ │ ← Full pixel coverage = SHARP
└──┴──┴──┴──┴──┘

Why willChange only on high DPR?

...(getDPR(elements.floating) >= 1.5 && {willChange: 'transform'}),
  • High-DPR screens benefit most from GPU acceleration
  • will-change has memory overhead
  • Low-DPR screens don't need the performance hint

Complete Data Flow

┌─────────────────────────────────────────────────────────────────┐
│ 1. INITIALIZATION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ const [data, setData] = useState({ │
│ x: 0, y: 0, placement, strategy, │
│ middlewareData: {}, isPositioned: false │
│ }); │
│ │
│ referenceRef = useRef(null) │
│ floatingRef = useRef(null) │
│ isMountedRef = useRef(false) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. USER ATTACHES REFS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ <button ref={refs.setReference}>Trigger</button> │
│ <div ref={refs.setFloating}>Tooltip</div> │
│ │
│ setReference(buttonEl): │
│ referenceRef.current = buttonEl (sync) │
│ _setReference(buttonEl) (triggers re-render) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. LAYOUT EFFECT RUNS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ useLayoutEffect(() => { │
│ if (referenceEl && floatingEl) { │
│ if (whileElementsMounted) { │
│ return whileElementsMounted(ref, float, update); │
│ } │
│ update(); │
│ } │
│ }, [referenceEl, floatingEl, ...]); │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. UPDATE FUNCTION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ update(): │
│ if (!referenceRef.current || !floatingRef.current) return │
│ │
│ computePosition(ref, float, config) │
│ .then(data => { │
│ if (!isMountedRef.current) return // Unmounted guard │
│ if (deepEqual(dataRef.current, data)) return // No-op │
│ │
│ dataRef.current = data │
│ flushSync(() => setData(data)) // Sync update! │
│ }) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 5. RE-RENDER WITH NEW DATA │
├─────────────────────────────────────────────────────────────────┤
│ │
│ floatingStyles = { │
│ position: 'absolute', │
│ left: 0, │
│ top: 0, │
│ transform: 'translate(150px, 200px)', │
│ } │
│ │
│ return { x, y, placement, isPositioned, floatingStyles, ... } │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 6. USER APPLIES STYLES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ <div │
│ ref={refs.setFloating} │
│ style={floatingStyles} │
│ > │
│ Tooltip at correct position │
│ </div> │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 7. ONGOING UPDATES (via autoUpdate) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User scrolls ──► autoUpdate calls update() ──► Position fixed │
│ Window resizes ► autoUpdate calls update() ──► Position fixed │
│ Layout shift ──► autoUpdate calls update() ──► Position fixed │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 8. CLEANUP (unmount) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ isMountedRef.current = false │
│ autoUpdate cleanup runs (removes listeners) │
│ Any pending computePosition results ignored │
│ │
└─────────────────────────────────────────────────────────────────┘

Full Implementation

Here's the complete source code with the patterns we discussed in action:

import {computePosition} from '@floating-ui/dom';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import useModernLayoutEffect from 'use-isomorphic-layout-effect';
import type {
ComputePositionConfig,
ReferenceType,
UseFloatingData,
UseFloatingOptions,
UseFloatingReturn,
} from './types';
import {deepEqual} from './utils/deepEqual';
import {getDPR} from './utils/getDPR';
import {roundByDPR} from './utils/roundByDPR';
import {useLatestRef} from './utils/useLatestRef';
export function useFloating<RT extends ReferenceType = ReferenceType>(
options: UseFloatingOptions = {},
): UseFloatingReturn<RT> {
const {
placement = 'bottom',
strategy = 'absolute',
middleware = [],
platform,
elements: {reference: externalReference, floating: externalFloating} = {},
transform = true,
whileElementsMounted,
open,
} = options;
// Position data state - drives the UI
const [data, setData] = React.useState<UseFloatingData>({
x: 0,
y: 0,
strategy,
placement,
middlewareData: {},
isPositioned: false,
});
// Middleware comparison (deep equality check)
const [latestMiddleware, setLatestMiddleware] = React.useState(middleware);
if (!deepEqual(latestMiddleware, middleware)) {
setLatestMiddleware(middleware);
}
// DUAL STATE + REF PATTERN: State triggers effects, refs give current values
const [_reference, _setReference] = React.useState<RT | null>(null);
const [_floating, _setFloating] = React.useState<HTMLElement | null>(null);
const setReference = React.useCallback((node: RT | null) => {
if (node !== referenceRef.current) {
referenceRef.current = node; // Immediate for async callbacks
_setReference(node); // Triggers effects
}
}, []);
const setFloating = React.useCallback((node: HTMLElement | null) => {
if (node !== floatingRef.current) {
floatingRef.current = node;
_setFloating(node);
}
}, []);
const referenceEl = (externalReference || _reference) as RT | null;
const floatingEl = externalFloating || _floating;
// Refs for always-current values
const referenceRef = React.useRef<RT | null>(null);
const floatingRef = React.useRef<HTMLElement | null>(null);
const dataRef = React.useRef(data);
// LATEST REF PATTERN: Avoid stale closures without adding dependencies
const hasWhileElementsMounted = whileElementsMounted != null;
const whileElementsMountedRef = useLatestRef(whileElementsMounted);
const platformRef = useLatestRef(platform);
const openRef = useLatestRef(open);
// THE UPDATE FUNCTION: Bridge between async computePosition and React
const update = React.useCallback(() => {
if (!referenceRef.current || !floatingRef.current) {
return;
}
const config: ComputePositionConfig = {
placement,
strategy,
middleware: latestMiddleware,
};
if (platformRef.current) {
config.platform = platformRef.current;
}
computePosition(referenceRef.current, floatingRef.current, config).then(
(data) => {
const fullData = {
...data,
isPositioned: openRef.current !== false,
};
// Guards: mounted check + deep equality to prevent loops
if (isMountedRef.current && !deepEqual(dataRef.current, fullData)) {
dataRef.current = fullData;
// flushSync for immediate updates during scroll/resize
ReactDOM.flushSync(() => {
setData(fullData);
});
}
},
);
}, [latestMiddleware, placement, strategy, platformRef, openRef]);
// Reset isPositioned when closing
useModernLayoutEffect(() => {
if (open === false && dataRef.current.isPositioned) {
dataRef.current.isPositioned = false;
setData((data) => ({...data, isPositioned: false}));
}
}, [open]);
// Track mounted state for async safety
const isMountedRef = React.useRef(false);
useModernLayoutEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// MAIN EFFECT: Connect elements and set up subscriptions
useModernLayoutEffect(() => {
if (referenceEl) referenceRef.current = referenceEl;
if (floatingEl) floatingRef.current = floatingEl;
if (referenceEl && floatingEl) {
if (whileElementsMountedRef.current) {
// Return cleanup function from whileElementsMounted (e.g., autoUpdate)
return whileElementsMountedRef.current(referenceEl, floatingEl, update);
}
update();
}
}, [
referenceEl,
floatingEl,
update,
whileElementsMountedRef,
hasWhileElementsMounted,
]);
// Memoized return values
const refs = React.useMemo(
() => ({
reference: referenceRef,
floating: floatingRef,
setReference,
setFloating,
}),
[setReference, setFloating],
);
const elements = React.useMemo(
() => ({reference: referenceEl, floating: floatingEl}),
[referenceEl, floatingEl],
);
// CSS OUTPUT: Transform-based positioning with DPR handling
const floatingStyles = React.useMemo(() => {
const initialStyles = {
position: strategy,
left: 0,
top: 0,
};
if (!elements.floating) {
return initialStyles;
}
const x = roundByDPR(elements.floating, data.x);
const y = roundByDPR(elements.floating, data.y);
if (transform) {
return {
...initialStyles,
transform: `translate(${x}px, ${y}px)`,
...(getDPR(elements.floating) >= 1.5 && {willChange: 'transform'}),
};
}
return {
position: strategy,
left: x,
top: y,
};
}, [strategy, transform, elements.floating, data.x, data.y]);
return React.useMemo(
() => ({
...data,
update,
refs,
elements,
floatingStyles,
}),
[data, update, refs, elements, floatingStyles],
);
}

Key Takeaways

  1. Dual state + ref pattern solves the tension between triggering effects and accessing current values in async callbacks

  2. useLatestRef keeps values fresh in callbacks without adding unstable dependencies

  3. flushSync ensures immediate rendering during rapid updates like scrolling

  4. isMountedRef guards against setState on unmounted components after async operations

  5. isPositioned enables smooth animations by distinguishing initial placement from subsequent updates

  6. Transform positioning with DPR rounding provides crisp, performant rendering

  7. whileElementsMounted abstracts the subscription lifecycle for continuous position updates

These patterns aren't unique to Floating UI - they're broadly applicable solutions for bridging imperative/async APIs with React's declarative model.

Series Navigation

This article is part of a deep dive series on Floating UI:

ArticleTopicKey Concepts
How positions are calculatedBrowser coordinates, placements, axes, computeCoordsFromPlacement
How positions are adjustedoffset, detectOverflow, shift, flip, pipeline architecture
Part 3: useFloating (this article)How it works in ReactDual state/ref, flushSync, whileElementsMounted, floatingStyles
If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee