Floating UI - useFloating

Published on
...

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, 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 (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 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

A single setReference call, end to end
sync — immediate async — schedules a re-render read here, not from state,so it is always current User clicks the buttonthat should show a tooltip setReference(buttonEl) referenceRef.current = buttonEl _setReference(buttonEl) React re-renders useLayoutEffect runs(before the browser paints) update() computePosition(referenceRef.current, ...) setData({ x, y, ... }) Browser paints the tooltipat the 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:

update() guarded against unmounting mid-flight
meanwhile false true Component mounts update() called computePosition() starts(ASYNC!) User navigates away Component unmountsisMountedRef.current = false Promise resolves isMountedRef.current? Return early(avoid setState on unmounted) 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:

Without isPositioned: tooltip jumps into place
Initial render: x=0, y=0 (default) Tooltip appears at (0,0)top-left corner! computePosition resolves Tooltip JUMPS to correct position

With isPositioned:

With isPositioned: tooltip fades in instead of jumping
Initial render: x=0, y=0, isPositioned=false Tooltip invisible(opacity: 0) computePosition resolvesisPositioned=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

autoUpdate subscription lifecycle, phase by phase
user scrolls window resizes content changes Phase 1 — Elements mountsetReference(btn) + setFloating(tooltip) Phase 2 — Effect runscalls whileElementsMounted(ref, float, update) Phase 3 — autoUpdate sets up listenersscroll · resize · ResizeObserver · IntersectionObserverreturns cleanup function update() called Phase 4 — Cleanup(unmount or elements change)removes listeners, disconnects 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

Complete data flow through useFloating, step by step
each trigger 1. Initializationcreate data state + refs (reference, floating, isMounted) 2. User attaches refssetReference(buttonEl), setFloating(tooltipEl) 3. Layout effect runscalls whileElementsMounted(...) or update() 4. update()guard checks → computePosition() → guarded setData() 5. Re-render with new datafloatingStyles computed 6. User applies floatingStylesto the floating element 7. Ongoing updates via autoUpdatescroll / resize / layout shift 8. Cleanup on unmountremove listeners, ignore pending results

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 XShare on Threads

Subscribe for updates

Get a weekly recap with extra context, process notes, and the thinking behind the work.

Buy me a coffee