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

[@xstate/store] Fix useSelector(…) infinite loop #5080

Closed

Conversation

davidkpiano
Copy link
Member

The useSelector(…) hook will no longer result in an infinite loop when an object-like value is returned from the selector function.

cc. @TkDodo - I had to bring back in useSyncExternalStoreWithSelector (copy-pasted, no dependencies), but if you have any better ideas for solving this, I'm open to a simpler solution.

Copy link

changeset-bot bot commented Sep 18, 2024

⚠️ No Changeset found

Latest commit: c917b1c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@@ -0,0 +1,101 @@
// Copied from https://github.com/facebook/react/blob/0a76aecbfa3d493c6b97998d0444f6505f5e5365/packages/use-sync-external-store/src/useSyncExternalStoreWithSelector.js
Copy link
Member

Choose a reason for hiding this comment

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

this hurts my soul, I completely miss the point of copy-pasting code just to claim the package has no redundant dependencies. Now imagine 10 libraries doing the same. As a result, the final application will have more code and not less.

Copy link
Member Author

Choose a reason for hiding this comment

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

If there's a way we can fix the existing code (cc @TkDodo) then let's do that. Otherwise, I don't mind reintroducing the dependency.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure there is something to fix. If you create a new object or array in your selector, the comparison method must account for that. Per default, we use === to compare, so it won't work.

It would need:

const count = useSelector(
  store,
  (s) => s.context.count.slice(),
  shallowEqual
);

Copy link
Member Author

Choose a reason for hiding this comment

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

I wonder why it worked with useSyncExternalStoreWithSelector then?

Copy link
Member

Choose a reason for hiding this comment

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

this is why #5084 :)

@@ -266,3 +266,35 @@ it('useActorRef (@xstate/react) should work', () => {

expect(countDiv.textContent).toEqual('1');
});

it('useSelector should not infinite loop', () => {
Copy link
Member

Choose a reason for hiding this comment

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

this is a known caveat listed in the React docs:

The store snapshot returned by getSnapshot must be immutable. If the underlying store has mutable data, return a new immutable snapshot if the data has changed. Otherwise, return a cached last snapshot.

Is it worth "patching" the problem on our side if this is just how things are "supposed" to work?

Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't zustand v5 have the same problem? The solution here is heavily inspired by how they do it:

https://github.com/pmndrs/zustand/blob/b93e11681d7b70c60dc99ae5296216a066f466b7/src/react/shallow.ts#L4

I think the solution here would be to have the user provide a different compare method like shallow. Otherwise, yes, returning new instances will have this impact.

Copy link
Member Author

@davidkpiano davidkpiano Sep 18, 2024

Choose a reason for hiding this comment

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

To clarify, the problem here isn't that it rerenders every time; that's expected and a good use-case for providing a different compare method.

The problem is that it will cause an infinite loop:

Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.

EDIT: Okay, I see that this is a caveat in Zustand as well: https://zustand.docs.pmnd.rs/migrations/migrating-to-v5#requiring-stable-selector-outputs

Copy link
Member

Choose a reason for hiding this comment

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

To clarify, the problem here isn't that it rerenders every time

The problem is that it will cause an infinite loop:

You are saying that what is a problem isn't a problem or that what isn't a problem is a problem :D

Copy link
Member

Choose a reason for hiding this comment

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

When this happens with the current code React even prints a warning about it:

The result of getSnapshot should be cached to avoid an infinite loop

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

Successfully merging this pull request may close these issues.

3 participants