Skip to content

[popups] Improve performance by batching ref access#3797

Closed
michaldudak wants to merge 13 commits intomui:masterfrom
michaldudak:batched-useStableCallback
Closed

[popups] Improve performance by batching ref access#3797
michaldudak wants to merge 13 commits intomui:masterfrom
michaldudak:batched-useStableCallback

Conversation

@michaldudak
Copy link
Copy Markdown
Member

@michaldudak michaldudak commented Jan 20, 2026

Created the useStableCallbacks hook to batch individual useStableCallback calls.
Used the new hook in useDismiss. After the change, components using useDismiss have 34 fewer hooks (for example, DialogRoot, 150 -> 116).

Performance benchmark results

Edge 144 @ M3 Pro, no throttling, all components (Dialog, Menu, Popover, Tooltip)

Contained triggers

https://deploy-preview-3797--base-ui.netlify.app/experiments/perf/contained-triggers

this PR: 150.5ms
master: 167ms

Detached triggers

https://deploy-preview-3797--base-ui.netlify.app/experiments/perf/detached-triggers

this PR: 72.3ms
master: 75ms

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jan 20, 2026

commit: 51fe9af

@mui-bot
Copy link
Copy Markdown

mui-bot commented Jan 20, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+2.67KB(+0.64%) 🔺+673B(+0.50%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 20, 2026

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 51fe9af
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/698da33a69eaa000083ebd7e
😎 Deploy Preview https://deploy-preview-3797--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@michaldudak
Copy link
Copy Markdown
Member Author

@greptileai review this

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jan 20, 2026

Greptile Overview

Greptile Summary

This PR optimizes performance by batching hook calls through two new utility hooks: useStableCallbacks and useValuesAsRef. These consolidate multiple individual useStableCallback and useValueAsRef calls into single hook invocations, reducing the hook count per component by 34 hooks (e.g., DialogRoot: 150 → 116).

Key changes:

  • Created useStableCallbacks to batch multiple stable callback definitions into a single hook with trampolines
  • Created useValuesAsRef to batch multiple ref conversions (supports up to 6 values)
  • Refactored useDismiss to consolidate 15+ separate refs and callbacks into unified objects
  • Updated hover interaction hooks to use the new batched utilities
  • Changed empty object return to use EMPTY_OBJECT constant for better reference stability

Performance improvements:

  • Contained triggers: 167ms → 150.5ms (10% faster)
  • Detached triggers: 75ms → 72.3ms (3.6% faster)

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The refactoring is well-designed with clear performance benefits. The new utility hooks follow established patterns (trampoline functions, ref batching), include proper dev-mode validation, and maintain backward compatibility. The changes are purely internal optimizations that don't alter external APIs or behavior. Benchmark results show measurable improvements, and the implementation correctly handles edge cases like React 17 compatibility and key stability checks.
  • No files require special attention

Important Files Changed

Filename Overview
packages/utils/src/useStableCallbacks.ts New batched hook that reduces overhead by consolidating multiple useStableCallback calls into one
packages/utils/src/useValuesAsRef.ts New utility hook that batches multiple useValueAsRef calls to reduce hook count
packages/react/src/floating-ui-react/hooks/useDismiss.ts Refactored to use useStableCallbacks instead of individual useStableCallback calls, consolidating refs into single object
packages/react/src/floating-ui-react/hooks/useHoverFloatingInteraction.ts Migrated from individual useStableCallback calls to batched useStableCallbacks for performance optimization
packages/react/src/floating-ui-react/hooks/useHoverReferenceInteraction.ts Updated to use useStableCallbacks and useValuesAsRef for better performance through hook batching

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

export function useStableCallbacks<T extends Record<string, Callback | undefined>>(
callbacksObj: T,
): StableCallbacks<T> {
const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Keys are captured only on first render. If callbacksObj gets new keys in later renders, no trampolines will be created for them. Consider adding dev-mode validation to warn if keys change between renders.

Suggested change
const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current;
const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current;
if (process.env.NODE_ENV !== 'production') {
const currentKeys = Object.keys(callbacksObj);
if (currentKeys.length !== stable.trampolines &&
!currentKeys.every(k => k in stable.trampolines)) {
console.warn('Base UI: useStableCallbacks keys changed between renders');
}
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/utils/src/useStableCallbacks.ts
Line: 49:49

Comment:
**style:** Keys are captured only on first render. If `callbacksObj` gets new keys in later renders, no trampolines will be created for them. Consider adding dev-mode validation to warn if keys change between renders.

```suggestion
  const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current;
  
  if (process.env.NODE_ENV !== 'production') {
    const currentKeys = Object.keys(callbacksObj);
    if (currentKeys.length !== stable.trampolines && 
        !currentKeys.every(k => k in stable.trampolines)) {
      console.warn('Base UI: useStableCallbacks keys changed between renders');
    }
  }
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jan 20, 2026
Copy link
Copy Markdown
Contributor

@romgrk romgrk Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case you're interested, I'm pretty sure it would be possible to re-use the same fastComponent() logic from #3445 to reduce the cost of multiple useStableCallback() calls to 2 hooks (down from O(n)), just like we did for useStore(). The advantage is that the ergonomy of the API doesn't change.

This PR's API is probably slightly more efficient though, because there is a single object declared once, whereas with multiple calls we'd need to accumulate the stable callbacks in an array incrementally, so a bit more bookkeeping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solution is also more straightforward and IMO easier to understand, as it's just another hook call, so I prefer it this way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it does requires code to be re-organized in a way where the callbacks are declared all at once, and it wouldn't work if the hook was modularized into multiple variants (e.g. see the useHover... hooks). The strength of the fast hooks approach is that it doesn't require the code using it to be aware of it or be organized in a specific way. It also works for all the other interaction hooks at once (e.g. useHover...) without requiring more refactoring.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm OK with requiring callbacks to be declared together via useStableCallbacks. It’s more explicit and doesn't introduce more "magic".
I’d rather keep this PR’s scope focused and avoid introducing fastComponent here unless we decide to standardize on that approach across the board.

@atomiks
Copy link
Copy Markdown
Contributor

atomiks commented Jan 22, 2026

Is it feasible to also combine React.useRef()/useValueAsRef calls into a single one (object ref), which could also help reduce hook calls?

@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jan 23, 2026
@michaldudak
Copy link
Copy Markdown
Member Author

Is it feasible to also combine React.useRef()/useValueAsRef calls into a single one (object ref), which could also help reduce hook calls?

I created a useValuesAsRef hook.
3 more hook calls saved in DialogRoot, but I don't see any improvements in mount times - perhaps the gain is too small.

@michaldudak michaldudak changed the title [popups] POC: useStableCallbacks [popups] Improve performance by batching ref access Jan 27, 2026
@michaldudak michaldudak added component: menu Changes related to the menu component. component: dialog Changes related to the dialog component. performance component: tooltip Changes related to the tooltip component. component: popover Changes related to the popover component. and removed poc labels Jan 27, 2026
@michaldudak michaldudak marked this pull request as ready for review January 27, 2026 09:36
@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Feb 3, 2026
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Feb 11, 2026
@michaldudak
Copy link
Copy Markdown
Member Author

This adds significant weight to the bundle while improving the performance only marginally. It's not worth it. I'll explore the fasthooks implementation instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: dialog Changes related to the dialog component. component: menu Changes related to the menu component. component: popover Changes related to the popover component. component: tooltip Changes related to the tooltip component. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants