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:
- - Understanding browser coordinates, placements, axes, and
computeCoordsFromPlacement - - The middleware pipeline:
offset,shift,flip, anddetectOverflow
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 (offset → shift → flip → 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 ASYNCcomputePosition(referenceEl, floatingEl, config).then(data => {// Returns { x, y, placement, middlewareData, ... }});// But React is DECLARATIVE and SYNCHRONOUSconst [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:
Using only refs means no re-renders:
// Problem: Ref changes don't trigger re-rendersconst 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 staleconst [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
useLayoutEffectwhen 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 depsif (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 existif (!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 existif (!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<divstyle={{...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 positionisPositioned: openRef.current !== false,// When open changes to falseuseLayoutEffect(() => {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() calledWindow resizes ──────┼───► update() calledContent 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 functionuseLayoutEffect(() => {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
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-changehas 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 UIconst [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 valuesconst [_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 valuesconst 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 dependenciesconst hasWhileElementsMounted = whileElementsMounted != null;const whileElementsMountedRef = useLatestRef(whileElementsMounted);const platformRef = useLatestRef(platform);const openRef = useLatestRef(open);// THE UPDATE FUNCTION: Bridge between async computePosition and Reactconst 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 loopsif (isMountedRef.current && !deepEqual(dataRef.current, fullData)) {dataRef.current = fullData;// flushSync for immediate updates during scroll/resizeReactDOM.flushSync(() => {setData(fullData);});}},);}, [latestMiddleware, placement, strategy, platformRef, openRef]);// Reset isPositioned when closinguseModernLayoutEffect(() => {if (open === false && dataRef.current.isPositioned) {dataRef.current.isPositioned = false;setData((data) => ({...data, isPositioned: false}));}}, [open]);// Track mounted state for async safetyconst isMountedRef = React.useRef(false);useModernLayoutEffect(() => {isMountedRef.current = true;return () => {isMountedRef.current = false;};}, []);// MAIN EFFECT: Connect elements and set up subscriptionsuseModernLayoutEffect(() => {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 valuesconst 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 handlingconst 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
Dual state + ref pattern solves the tension between triggering effects and accessing current values in async callbacks
useLatestRef keeps values fresh in callbacks without adding unstable dependencies
flushSync ensures immediate rendering during rapid updates like scrolling
isMountedRef guards against setState on unmounted components after async operations
isPositioned enables smooth animations by distinguishing initial placement from subsequent updates
Transform positioning with DPR rounding provides crisp, performant rendering
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:
