Skip to content

Conversation

@Inwerpsel
Copy link

@Inwerpsel Inwerpsel commented Apr 9, 2022

What?

Create a new function that can be called instead of useSelect to get a store's selectors.

export default function useSelectors( storeName ) {
	const registry = useRegistry();

	return registry.select( storeName );
}

Remove complexity from useSelect now that the function only does 1 thing.

Why?

In the previous code, 1 function is used to cover 2 use cases, switching between them with the first parameter's type. This never changes dynamically, code never depends on the function being able to do 2 things.

The original use case is to get data needed during render.

The 2nd use case simply returns a store's selectors, so they can be called in event handlers.

This use case does not need most of the hooks the original use is calling. However because of how hooks work, they needed to be called anyway in this path.

In the new function, the only remaining hook call for getting the controls is to useRegistry. All the other logic inside useSelect is completely irrelevant.

It also allows to simplify useSelect. It won't need any of the checks on the type of argument anymore, and can simply return the map output. For now I kept this out of this branch to make it easier to provide BC.

I didn't test the impact of not having to call these hooks, but I guess it's not 0. Especially if many components use this.

How?

  • Add a new function that only gets the selectors.
  • Make all usages of useSelect use this function instead.
  • Remove complexity from useSelect that was only there to make the function do 2 things.

Testing Instructions

I tried to locate all relevant uses of the function, so depending on test coverage it should be relatively easy to confirm I didn't miss any.

Awaiting feedback on what to do with the public API, it could be that useSelect would preserve its duplicate function. If that's the case then this change should involve very little risk. For now I indeed kept useSelect unchanged so that this PR is unblocked. Perhaps in a follow up it's possible to add the simplified form already and keep a copy of the older one for BC.

TODO

The function is exposed with the old signature as a public API. While in practice it should work to check the argument and call the other function, it's being picked up by linting as a rules of hooks violation. Which it technically is, but in practice it only depends on the type which stays stable over time. Still seems like a non optimal solution and a bad example to set.

One option is to leave the old behavior in place in useSelect. That would also make it safer in case a usage was missed in this PR.

Alternatively keep an old copy of useSelect and export that as a public API. Then internal use could already use the simplified function.

@github-actions github-actions bot added the First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository label Apr 9, 2022
@github-actions
Copy link

github-actions bot commented Apr 9, 2022

👋 Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @Inwerpsel! In case you missed it, we'd love to have you join us in our Slack community, where we hold regularly weekly meetings open to anyone to coordinate with each other.

If you want to learn more about WordPress development in general, check out the Core Handbook full of helpful information.

@ZebulanStanphill
Copy link
Member

I absolutely agree that this is something that should be done, but because of backward compatibility requirements, we will almost certainly have to deprecate the 2nd use-case of useSelect (and promote the usage of useSelectors instead) for several releases before we can actually remove it.

@skorasaurus skorasaurus added the [Type] Code Quality Issues or PRs that relate to code quality label Apr 9, 2022
@Mamaduka Mamaduka added the [Type] New API New API to be used by plugin developers or package users. label Apr 10, 2022
@Mamaduka Mamaduka requested a review from jsnajdr April 10, 2022 07:02
@Inwerpsel
Copy link
Author

@ZebulanStanphill Would it make sense to make it use the new version internally and keep a copy of the older one, just for exporting the API for now? I explored this here. Seems manageable as this function is supposed to not change. Only drawback is it would probably require changing every internal import of useSelect.

@Inwerpsel Inwerpsel force-pushed the avoid-swiss-army-knife-use-select branch from 31b0c26 to d1db6b1 Compare April 11, 2022 08:17
Copy link
Member

@jsnajdr jsnajdr left a comment

Choose a reason for hiding this comment

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

I'm wondering whether we need to introduce the new useSelectors hook.

useRegistry().select( store )

is already very concise and does the same thing.

We can't remove the ! hasMappingFunction functionality right now because of backward compatibility. We'll need to mark it as deprecated and remove only much later.

One way to simplify the implementation is to make use of the fact that consumers can continue to use useSelect in either of the two modes, but they are extremely unlikely to switch between them at runtime. In other words, nobody ever does this:

const selectResult = useSelect( props.condition ? 'core' : ( select ) => { ... } );

A particular hook instance starts in certain mode and stays in that mode forever.

We can exploit that to write the hook this way:

function useSelect( storeOrMap ) {
  const mode = typeof storeOrMap === 'function' ? 'map' : 'select';
  const initialMode = useRef( mode );
  if ( mode !== initialMode ) {
    throw new Error( `useSelect hook tried to switch from ${ initialMode } to ${ mode } mode and changing mode is not possible.` );
  }
  if ( mode === 'select' ) {
    return useRegistry().select( storeOrMap );
  }

  /* Proceed to execute the complex mapping and subscribing hook */
}

This way we can avoid having to call all the dummy hooks.

_Parameters_

- _mapSelect_ `Function|StoreDescriptor|string`: Function called on every state change. The returned value is exposed to the component implementing this hook. The function receives the `registry.select` method on the first argument and the `registry` on the second argument. When a store key is passed, all selectors for the store will be returned. This is only meant for usage of these selectors in event callbacks, not for data needed to create the element tree.
- _mapSelect_ `Function|StoreDescriptor|string`: Function called on every state change. The returned value is exposed to the component implementing this hook. The function receives the `registry.select` method on the first argument and the `registry` on the second argument.
Copy link
Member

Choose a reason for hiding this comment

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

With the useSelectors behavior deprecated/removed, the mapSelect type is now merely Function.


_Parameters_

- _storeName_ `string`: Key of the store to get controls for. **Don't use `useSelect` for calling the selectors in the render function because your component won't re-render on a data change. You need to use useSelect in that case.** `js import { useSelect } from '@wordpress/data'; function Paste( { children } ) { const { getSettings } = useSelect( 'my-shop' ); function onPaste() { // Do something with the settings. const settings = getSettings(); } return <div onPaste={ onPaste }>{ children }</div>; }`
Copy link
Member

Choose a reason for hiding this comment

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

The storeName type is StoreDescriptor | string.

The `useSelect` function is used to cover 2 use cases, switching
between them with the first parameter's type.

The original use case is to fetch data during render. The 2nd use case
simply returns a store's selectors, so they can be called in event
handlers.

This 2nd use case does not need most of the hooks the original use is
calling. However because of how hooks work, they needed to be called
anyway in this path.

In the new function, the only remaining hook call for getting the
store's selectors is to `useRegistry`.

It also allows to simplify useSelect, which doesn't need any of the
checks on the type of argument anymore, and can simply return the map
output.

I didn't test the impact of not having to call
these hooks, but I guess it's not 0. Especially if many components use
this.

For now `useSelect` preserves its double function, because it is
exported as a public API. In a next step it's possibel to internally
already use a simpler form that does 1 thing, and keep a copy of the old
one only for exporting the API before it's removed.
@Inwerpsel Inwerpsel force-pushed the avoid-swiss-army-knife-use-select branch from d1db6b1 to 1e4363a Compare April 11, 2022 08:31
@Inwerpsel
Copy link
Author

Thanks for the quick feedback @jsnajdr! I had just removed the changes from useSelect about the same time, but I might re-add them based on the suggestions.

is already very concise and does the same thing

I also considered this, though a custom hook is even slightly more concise. The change compared to useSelect is also less, you only need to use a different function. That would make it easier for people to switch to the new API.

A particular hook instance starts in certain mode and stays in that mode forever.
We can exploit that to write the hook this way:

I tried this and though it should work, it's picked up as a violation of the rules of hooks. But I agree this is the "cleanest" solution, allowing to simplify the logic already.

@jsnajdr
Copy link
Member

jsnajdr commented Apr 11, 2022

it's picked up as a violation of the rules of hooks.

That's fine, the lint rule can't understand what we're up to. We're breaking the rules very intentionally here.

@Inwerpsel
Copy link
Author

I'll try this later today/tomorrow 🤞

This is a deliberate rules-of-hooks violation that is safe to do
because the condition stays same for each invocation, mapSelect's type
never changes.

Unfortunately it meant disabling the rules-of-hooks check in the whole
function body, but there's no getting around that. However the chance
for the rule to be needed in this function is rather small.
@Inwerpsel Inwerpsel force-pushed the avoid-swiss-army-knife-use-select branch from e4db283 to a7140b9 Compare April 11, 2022 16:18
@Mamaduka
Copy link
Member

I'm going to close the PR. Why?

  • The useSelect hook was refactored in useSelect: implement with useSyncExternalStore #46538, which reduced some complexity when subscribing to the store and using a static getter.
  • I don't think introducing yet another data hook will help DX. We can always make useStaticSelect public in the future.
  • The change doesn't justify the amount of refactoring required for core code, IMO.

@Mamaduka Mamaduka closed this Mar 19, 2025
@Inwerpsel
Copy link
Author

I primarily created this PR because, at the time, the way in which the 2 different uses were merged into a single function caused a significant performance impact and maintainability concern, because hooks used in 1 of 2 uses had to be called constantly on every render in both uses anyway.

I guess with the refactors that are mentioned, the scope of that problem has been reduced since less hooks are called.

Now I haven't really followed this part of WP the last couple years, so I may be missing something. But afaict it's still, at runtime, figuring out which of the 2 cases it's in, even though they're mutually exclusive and the code literally says they should only ever be using 1 of the 2 paths per invocation.

// On initial call, on mount, determine the mode of this `useSelect` call
// and then never allow it to change on subsequent updates.

Consider that when you step through debug your own components that interact with this API, you will very often run through sections like this:

if ( staticSelectMode !== staticSelectModeRef.current ) {
const prevMode = staticSelectModeRef.current ? 'static' : 'mapping';
const nextMode = staticSelectMode ? 'static' : 'mapping';
throw new Error(
`Switching useSelect from ${ prevMode } to ${ nextMode } is not allowed`
);
}

While I understand the hesitation to split a single API into 2, especially if it's common, also consider the DX argument that this code will run every time render of every component that calls useSelect, and it doesn't exactly help step through debugging.

The fact that the code has to runtime check and throw an error in case someone switch to the other use of the function, seems quite fragile as well.

@Inwerpsel
Copy link
Author

Inwerpsel commented Mar 19, 2025

As for the amount of changes not being justified, it's nowhere near as much as it looks like just glancing the numbers.

The PR has 2 types of changes:

  • Changes to the hooks: simplify useSelect, add useSelector (really simple function)
  • Change some uses of useSelect to useSelector, with no changes to the surrounding logic

I remember that it didn't take very long to create these changes, and most of my time was spent figuring out what the existing thing was supposed to do 😄

@jsnajdr
Copy link
Member

jsnajdr commented Mar 19, 2025

It was a design mistake to overload useSelect with two completely different functions, but now it's hard to go back.

useSelectors can be very simply written as:

const selectors = useRegistry().select( blocksStore );

so if you want to avoid the "static" version of useSelect, you can do this. It's just 8 characters more than when you have useSelectors.

@Inwerpsel
Copy link
Author

Inwerpsel commented Mar 19, 2025

Why is it hard to go back? Does this code really, for eternity, will need to ask itself which mode it is in and whether it should throw an error, every time your component renders an update to the screen, even though it's not supposed to change?

To be clear, I'm not at all requesting this be solved now, or the PR reopened. In fact, I kept the PR open just to demonstrate what the problem is and that it can be fixed. So I'm just mentioning here for future reference that this quirk still exists and might be addressable when changing the architecture of state management at some point in the future.

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

Labels

First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository [Type] Code Quality Issues or PRs that relate to code quality [Type] New API New API to be used by plugin developers or package users.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants