Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(hooks): Optimize useSyncExternalStoreWithSelector selector calls #32040

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

Biki-das
Copy link
Contributor

@Biki-das Biki-das commented Jan 10, 2025

Summary

Add selector execution tracking to useSyncExternalStoreWithSelector to avoid unnecessary re-renders and selector calls. The hook now tracks which properties are accessed during selection and only re-runs the selector when those specific properties change.

  • Add Proxy-based property access tracking
  • Track accessed properties to determine relevant state changes
  • Skip selector execution when only irrelevant state changes
  • Add tests verifying selector optimization behavior

This improves performance by reducing unnecessary selector executions when unrelated parts of the state change.

How did you test this change?

Have added related test to test selector calls on specific scenarios.

Screenshot 2025-01-10 at 10 18 25 PM

@Biki-das
Copy link
Contributor Author

Biki-das commented Jan 10, 2025

cc @markerikson
also @josephsavona
would love to know your thoughts and if these changes affect redux?

@react-sizebot
Copy link

react-sizebot commented Jan 10, 2025

Comparing: 0bf1f39...ff44581

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB +0.05% 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 513.96 kB 513.96 kB = 91.78 kB 91.78 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB +0.05% 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 550.36 kB 550.36 kB = 97.97 kB 97.97 kB
facebook-www/ReactDOM-prod.classic.js = 595.79 kB 595.79 kB = 104.85 kB 104.85 kB
facebook-www/ReactDOM-prod.modern.js = 586.21 kB 586.21 kB = 103.31 kB 103.31 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-with-selector.production.js +60.36% 2.63 kB 4.22 kB +43.80% 0.86 kB 1.24 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-with-selector.production.js +60.36% 2.63 kB 4.22 kB +43.80% 0.86 kB 1.24 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-with-selector.production.js +60.36% 2.63 kB 4.22 kB +43.80% 0.86 kB 1.24 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.production.js +59.14% 2.69 kB 4.27 kB +43.30% 0.88 kB 1.26 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.production.js +59.14% 2.69 kB 4.27 kB +43.30% 0.88 kB 1.26 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.production.js +59.14% 2.69 kB 4.27 kB +43.30% 0.88 kB 1.26 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-with-selector.development.js +51.96% 3.47 kB 5.27 kB +36.97% 1.02 kB 1.39 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-with-selector.development.js +51.96% 3.47 kB 5.27 kB +36.97% 1.02 kB 1.39 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-with-selector.development.js +51.96% 3.47 kB 5.27 kB +36.97% 1.02 kB 1.39 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +51.11% 3.53 kB 5.33 kB +36.53% 1.03 kB 1.41 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +51.11% 3.53 kB 5.33 kB +36.53% 1.03 kB 1.41 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +51.11% 3.53 kB 5.33 kB +36.53% 1.03 kB 1.41 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-with-selector.production.js +60.36% 2.63 kB 4.22 kB +43.80% 0.86 kB 1.24 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-with-selector.production.js +60.36% 2.63 kB 4.22 kB +43.80% 0.86 kB 1.24 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-with-selector.production.js +60.36% 2.63 kB 4.22 kB +43.80% 0.86 kB 1.24 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.production.js +59.14% 2.69 kB 4.27 kB +43.30% 0.88 kB 1.26 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.production.js +59.14% 2.69 kB 4.27 kB +43.30% 0.88 kB 1.26 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.production.js +59.14% 2.69 kB 4.27 kB +43.30% 0.88 kB 1.26 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-with-selector.development.js +51.96% 3.47 kB 5.27 kB +36.97% 1.02 kB 1.39 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-with-selector.development.js +51.96% 3.47 kB 5.27 kB +36.97% 1.02 kB 1.39 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-with-selector.development.js +51.96% 3.47 kB 5.27 kB +36.97% 1.02 kB 1.39 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +51.11% 3.53 kB 5.33 kB +36.53% 1.03 kB 1.41 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +51.11% 3.53 kB 5.33 kB +36.53% 1.03 kB 1.41 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-shim/with-selector.development.js +51.11% 3.53 kB 5.33 kB +36.53% 1.03 kB 1.41 kB

Generated by 🚫 dangerJS against 85ef2ab

Add intelligent selector execution tracking to useSyncExternalStoreWithSelector
to avoid unnecessary re-renders and selector calls. The hook now tracks which
properties are accessed during selection and only re-runs the selector when
those specific properties change.

- Add Proxy-based property access tracking
- Track accessed properties to determine relevant state changes
- Skip selector execution when only irrelevant state changes
- Add tests verifying selector optimization behavior
- Preserve existing memoization and isEqual behavior

This improves performance by reducing unnecessary selector executions when
unrelated parts of the state change.
@markerikson
Copy link
Contributor

This would definitely have an effect, but I don't know if it would be positive or negative.

I've done some prior research into something vaguely similar:

and @dai-shi also had a PR a few years back that tried to implement proxy-based tracking updates:

@dai-shi knows a lot more about overall Proxy performance and behavior in this kind of scenario.

I'll definitely have to take a look at this and see what it actually does and how that affects various performance scenarios.

That said, a general thought on the implementation approach:

even if this does make an improvement on overall perf, I would say that changing the existing useSyncExternalStoreWithSelector implementation is probably not the right way to do this, for compat purposes. Instead I'd recommend making it a separate function (and file/package export?) entirely.

@markerikson
Copy link
Contributor

markerikson commented Jan 10, 2025

I'm looking at this, and I like the concept.

My WIP "autotracking" PR from a couple years ago was trying to do deep tracking. That works better in terms of theoretical changes (the todos.map(t => t.id) scenario), but is a lot more expensive (having to Proxy-wrap all the nested fields, instantiate all tracking objects, etc).

This one looks like it just tracks top-level accessed field names. That ought to be a lot faster, and also feels like it gives the most bang for the buck. It ties into what Slack told us about their homegrown "top level changed fields subscriptions" wrapper for their Redux store being a boost for perf, but that they didn't get as much benefit from trying to track deeper field changes.

I'd love to see some perf benchmarks for various kinds of state updates to get a sense of how this may improve things.

@Biki-das
Copy link
Contributor Author

Thanks @markerikson for the detailed feedback!will try to test this out and see its effect.

@markerikson
Copy link
Contributor

Also worth considering what happens if the wrapped state is a primitive instead of an object as well.

@Biki-das
Copy link
Contributor Author

Yeah.

@dai-shi
Copy link
Contributor

dai-shi commented Jan 11, 2025

Use proxy-memoize. 😄
It's been quite stable, fixing various edge cases over the past few years. I can consider adding shallow option if it's the requirement. (or probably shallow version.)

@markerikson
Copy link
Contributor

@dai-shi Not quite sure I follow. proxy-memoize is a selector library, right? The goal of this PR is to avoid actually even running the provided selector in the first place (essentially run a "pre-selector" kind of check).

@dai-shi
Copy link
Contributor

dai-shi commented Jan 11, 2025

As I understand, proxy-memoize works the same as proposed here, with tracking deeply.
It avoids calling the selector if none of touched properties are changed, and returns the cached result. (with which getSnapshot prevents rerendering.)

That said, if what we want is the shallow tracking and the use case is limited to useSyncExternalStoreWithSelector, there can be a much simpler implementation.

const memoizedSelector = (nextSnapshot: Snapshot) => {
const getProxy = (): Snapshot => {
if (
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@markerikson you were curious for the case when a a primitive value is passed, this lines will handle the same.
For Primitive Values

  1. No Proxy wrapping occurs
  2. The primitive value is passed directly to the selector
  3. The property access tracking system (with accessedProps) is bypassed entirely

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

Successfully merging this pull request may close these issues.

5 participants