Skip to content

[internal] Performance: fast useStore implementation#3445

Merged
michaldudak merged 20 commits intomui:masterfrom
romgrk:perf-fast-store
Jan 16, 2026
Merged

[internal] Performance: fast useStore implementation#3445
michaldudak merged 20 commits intomui:masterfrom
romgrk:perf-fast-store

Conversation

@romgrk
Copy link
Copy Markdown
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

This change results in a 15-35% decrease in mount time for the contained tooltip triggers benchmark.

TBD: re-run benchmarks

@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
Copy Markdown

pkg-pr-new Bot commented Dec 5, 2025

  • vite-css-base-ui-example

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

commit: 1f83bd0

@mui-bot
Copy link
Copy Markdown

mui-bot commented Dec 5, 2025

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+1.47KB(+0.35%) 🔺+624B(+0.47%)

Details of bundle changes


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

@netlify
Copy link
Copy Markdown

netlify Bot commented Dec 5, 2025

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 1f83bd0
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/696a556c0af0c80008cb7af4
😎 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
Copy Markdown

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.

Comment thread packages/utils/src/fastHooks.ts Outdated
Comment thread packages/utils/src/fastHooks.ts Outdated
Comment thread packages/utils/src/fastHooks.ts
Comment thread packages/react/src/tooltip/root/TooltipRoot.tsx Outdated
@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
Comment thread packages/utils/src/fastHooks.ts Outdated
@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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually there wouldn't be less performance gains, there would only be more bundle size. Even if another library were to take BUI components and add wrappers around it with a different fast hooks version, those wrappers would still be different components that would use their own version of fast hooks, without interfering with this one. Using a globalThis state might actually introduce more bugs and break stuff, and using a module-global variable like here is more sound.

Copy link
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Gotcha, so in this example of MenuRoot we want to batch all store.useState calls, while some of them might be made in custom hooks, correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's correct.

@mui mui deleted a comment from Copilot AI Dec 10, 2025
@romgrk romgrk marked this pull request as ready for review December 12, 2025 06:40

let result;
try {
currentInstance = instance;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

WDYT about attaching the current instance to the component function instead of keeping it global to solve the concurrent rendering problem?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

What do you mean by attaching to the component function? And which concurrent rendering problem?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

which concurrent rendering problem

To expand on that, AFAIU concurrent rendering can interrupt rendering a React commit, but it does so in between component renders — it doesn't stack component render functions on top of another. In other words, concurrent renderering is not parallel rendering. So as long as one component render function exits/enters without another component render function being entered, then this implementation is sound.

Even if React was entering multiple component functions, we could use this implementation to push/pop the current instance on the stack, but I think it's safe to assume that React doesn't and won't ever do so — it would complexify their codebase substantially.

const previousInstance = currentInstance
currentInstance = instance
const result = render(...args)
currentInstance = previousInstance

So my analysis is that concurrent rendering is not an issue, despite what copilot commented above. Server rendering is no different as we're always in a single-threaded context in javascript.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What do you mean by attaching to the component function?

I meant having the instance and hooks as properties on the component function. But this won't help if React ever interleaves render functions of two instances of the same component function.

My biggest worry is that this breaks the assuptions React has about user code - it makes renders unpure. While all the tests pass today, we can't be sure React won't change anything in their internal implementation that will make it introduce subtle bugs. Also we don't test our implementation with suspended components. Concurrent rendering is poorly documented, so I'm not sure what's exactly going on there - perhaps that's the main reason I'm cautious.

But perhaps someone with better knowledge of React internals could chime in here. @eps1lon, can I pick your brains (or is there someone who can know this better)? Is the solution proposed here safe enough?

Copy link
Copy Markdown
Contributor Author

@romgrk romgrk Dec 15, 2025

Choose a reason for hiding this comment

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

My biggest worry is that this breaks the assuptions React has about user code - it makes renders unpure

React hooks are impure to start with — they're stateful functions that depend on hidden global state, and they even have weird rules that don't exists anywhere else. If React hooks are impure, then render functions that use hooks are also impure, by definition. This is just to say that complete purity has never been an option, no language can ever be completely pure, not even Haskell, but the absence of purity also doesn't mean that we can't make a function idempotent and well-behaved. The trick is just to make our impurity dependent on stuff that comes out of React hooks, which currentInstance does as it's dependent on a ref.

But this won't help if React ever interleaves render functions of two instances of the same component function.

I haven't been able to imagine an issue with that. This hook checks if the current render differs from the previous one. If there is interleaving of different React commits, from this hook's point of view it's the same thing as if React was rendering the commits in full (aka without concurrent rendering): render functions are called one after the other. The only problem I could imagine is if React tries to render an older commit after a newer commit — but that would break a lot of user code (think about the hacks we sometimes put in effects & refs), so I feel that assumption is safe to make.

Also we don't test our implementation with suspended components

I have considered that case. Essentially, suspense throws stuff around, that's why I'm wrapping with try / finally — and finally is guaranteed by the language spec to be executed before the stack exits the function. I also placed the instance.didInitialize = true inside the try block (and not the finally block) precisely because I have considered the scenario where Suspense interrupts a component's first render(s) mid-way.

Signed-off-by: Rom Grk <romgrk@users.noreply.github.com>
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Dec 14, 2025
Comment thread babel.config.mjs Outdated

let result;
try {
currentInstance = instance;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What do you mean by attaching to the component function?

I meant having the instance and hooks as properties on the component function. But this won't help if React ever interleaves render functions of two instances of the same component function.

My biggest worry is that this breaks the assuptions React has about user code - it makes renders unpure. While all the tests pass today, we can't be sure React won't change anything in their internal implementation that will make it introduce subtle bugs. Also we don't test our implementation with suspended components. Concurrent rendering is poorly documented, so I'm not sure what's exactly going on there - perhaps that's the main reason I'm cautious.

But perhaps someone with better knowledge of React internals could chime in here. @eps1lon, can I pick your brains (or is there someone who can know this better)? Is the solution proposed here safe enough?

romgrk and others added 2 commits December 15, 2025 17:58
Co-authored-by: Michał Dudak <michal.dudak@gmail.com>
Signed-off-by: Rom Grk <romgrk@users.noreply.github.com>
@michaldudak
Copy link
Copy Markdown
Member

I tested it quite extensively today and couldn't find any issues.

Add comments to the code explaining the implementation and rationale to reduce the number of raised eyebrows of external contributors and future us.

We're going to have a 1.0.1 release next week, and I'd like to have this merged in after the release, so it lives on master for another month, just in case we find something unexpected.

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

romgrk commented Jan 16, 2026

This is ready for merging on my side, please approve if there's nothing blocking.

@michaldudak michaldudak merged commit c1c7f67 into mui:master Jan 16, 2026
23 checks passed
@romgrk romgrk deleted the perf-fast-store branch January 16, 2026 19:48
hooks.push(hook);
}

export function fastComponent<P extends object, E extends HTMLElement, R extends React.ReactNode>(
Copy link
Copy Markdown
Member

@oliviertassinari oliviertassinari Jan 17, 2026

Choose a reason for hiding this comment

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

Should we add documentation for the next person to explain when to use this and its limits?

Beyond contributor value, there also seems to be user value: in the eye of someone who is used to canonical React code, this is advanced, so it looks like an opportunity to teach them something, so to gain more trust from them in us.

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 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.

6 participants