Skip to content

Conversation

@romgrk
Copy link
Contributor

@romgrk romgrk commented Dec 5, 2025

Problem

The useStore() hook can be called many times per component. The naive implementation defers to React's useSyncExternalStore(), which creates one store subcription per call, which means one component ends up with multiple subscriptions to the store. This is unefficient because the component re-renders as an unit, and only requires 1 subscription.

Solution

This PR introduces a wrapper around components to establish an instance context object per component instance, which we can use to call useSyncExternalStore() at most once per component. Subsequent calls just accumulate the selectors in an array, without calling useSyncExternalStore() again.

The one downside of this solution is that we need to wrap components to make store accesses faster, e.g.:

// before
const TooltipTrigger = (props) => { /* ... */ }

// after
const TooltipTrigger = fastHooks.create((props) => { /* ... */ })

Results

I observe a decrease of 15-35% in mount time for the contained tooltip triggers benchmark.

image image

@romgrk romgrk added the type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. label Dec 5, 2025
@romgrk romgrk added performance internal Behind-the-scenes enhancement. Formerly called “core”. labels Dec 5, 2025
@romgrk romgrk marked this pull request as draft December 5, 2025 22:18
@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 5, 2025

  • vite-css-base-ui-example

    pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/react@3445
    
    pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/utils@3445
    

commit: ec0252e

@mui-bot
Copy link

mui-bot commented Dec 5, 2025

Bundle size report

Bundle Parsed size Gzip size
@base-ui-components/react 🔺+1.47KB(+0.36%) 🔺+558B(+0.43%)

Details of bundle changes


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

@netlify
Copy link

netlify bot commented Dec 5, 2025

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 8dfc8eb
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/693863b693796a0008f95a6c
😎 Deploy Preview https://deploy-preview-3445--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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a performance optimization for the useStore hook by implementing a "fast hooks" pattern that reduces store subscriptions from multiple-per-component to one-per-component. The optimization wraps components to establish an instance context, enabling useSyncExternalStore to be called once while accumulating multiple selectors. This is applied to React 19+ and results in 15-35% faster mount times for tooltip triggers.

Key changes:

  • New fastHooks module with component wrappers (create, createRef) that manage per-instance hook state
  • useStoreFast implementation that batches multiple useStore calls into a single subscription
  • Updated TooltipRoot and TooltipTrigger to use the new fast hooks wrappers

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.

File Description
packages/utils/src/fastHooks.ts New module providing component wrappers that establish instance context for optimized hook batching
packages/utils/src/store/useStore.ts Adds useStoreFast implementation that leverages instance context to batch store subscriptions
packages/react/src/tooltip/trigger/TooltipTrigger.tsx Wraps component with fastHooks.createRef to enable store subscription optimization
packages/react/src/tooltip/root/TooltipRoot.tsx Wraps component with fastHooks.create to enable store subscription optimization

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

index: number;
};

let currentInstance: Instance | undefined = undefined;
Copy link

Copilot AI Dec 6, 2025

Choose a reason for hiding this comment

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

The currentInstance global variable could cause issues in concurrent rendering scenarios (e.g., React 18+ concurrent features, Server Components). If multiple components render concurrently, they could overwrite each other's currentInstance, leading to hooks being associated with the wrong component instance. Consider using a context-based approach or ensure this is only used in synchronous rendering contexts.

Copilot uses AI. Check for mistakes.
@mui mui deleted a comment from Copilot AI Dec 6, 2025
@mui mui deleted a comment from Copilot AI Dec 6, 2025
@mui mui deleted a comment from Copilot AI Dec 6, 2025
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Dec 9, 2025
after: (instance: any) => void;
};

const hooks: HookType[] = [];
Copy link
Member

Choose a reason for hiding this comment

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

Singleton in the utils package here, but I guess it's not harmful, even if utils package gets duplicated, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've been considering de-duplicating the global state at runtime (const hooks = globalThis.fastHooks_v1 ??= []), but it's indeed not harmful. There is no logical bug from doing so, only lesser gains.

Copy link
Member

Choose a reason for hiding this comment

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

Did you consider a more explicit approach, where a single store.useState accepts multiple selectors and composes them together?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not really possible, some store calls might be spread across multiple files & hooks. The "popup" logic is shared by many components (tooltip, menu, popover) with hooks like useFocus or useTriggerRegistration accessing the common state interface from outside the component.

@mui mui deleted a comment from Copilot AI Dec 10, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Behind-the-scenes enhancement. Formerly called “core”. performance PR: out-of-date The pull request has merge conflicts and can't be merged. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants