The useSelect Infinite Loop: When a Selector Returns a New Reference on Every Render

The editor is not supposed to re-render when you type in an unrelated input. When it does, and the React console is either silent or repeating The result of getSnapshot should be cached, the first place to look is not the block editor store. It is the return value of your useSelect callback.

This is a failure-mode walkthrough: the symptom, the trace through @wordpress/data and React, the root cause, the fix, and a verification procedure that does not depend on vibes.

Symptom

You have a plugin or theme component that reads from core/block-editor, core/editor, or core. It works. Then one of the following happens:

  • The component re-renders on every dispatch, including dispatches that have nothing to do with the selected data.
  • React throws The result of getSnapshot should be cached and the editor becomes unusable.
  • In development mode, a useSelect warning appears in the console about returning a new object or array reference on each call.
  • Performance degrades as the post grows, because every block change triggers a render of a component that only cares about one boolean.

These are the same bug wearing different hats. The common thread is that the callback passed to useSelect returns a value that is not referentially stable between calls.

Trace: what useSelect actually compares

The WordPress Developer News article on useSelect states the precise condition for a re-render: it happens “when the change causes the new return value of the useSelect callback to not be shallowly equal to the previous one.” The comparison is shallow, implemented through @wordpress/is-shallow-equal. It is not a deep equality check, and it is not a reference check on the callback itself.

That distinction matters. A new object literal is never === to the previous object literal, even if every property inside it is identical. Shallow equality compares the fields one by one, but the container is new. For arrays, the same applies: a new array instance is a new value.

React’s useSyncExternalStore documentation describes the same contract from the React side. getSnapshot must return the same value while the store has not changed. The docs are explicit about the consequence: “React will re-render the component if getSnapshot return value is different from the last time. This is why, if you always return a different value, you will enter an infinite loop and get this error.” The WordPress data layer is built on this contract, so the failure mode transfers directly.

In other words: useSelect is not broken. It is doing exactly what it promises. The selector is returning a value that looks changed on every call.

Two failure shapes that look identical

There are two distinct ways to produce an unstable return value, and they require different fixes. Treating them as one bug leads to sprinkling useMemo everywhere, which is not the same as making the selector stable.

Shape A: a new container even when nothing changed

The canonical example is defaulting a null selector result to an empty array inside the callback:

const { reusableBlocks } = useSelect( ( select ) => {
  const blocks = select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
  return { reusableBlocks: blocks ?? [] };
}, [] );

When blocks is null, the ?? [] expression creates a new array on every invocation. The store has not changed. The data is still absent. But the return value is a new object containing a new array, so shallow equality fails and the component re-renders.

This is not hypothetical. Gutenberg PR #46117 describes exactly this pattern in useBlockEditorSettings: “When the selector returns null, it defaults to an empty array. But that empty array is a new object on every call, leading to useSelect always returning a different result, triggering a component rerender on each action dispatch.” The fix in that PR was to extract a constant empty array.

Shape B: a new container because the data genuinely changed

The second shape is subtler because the underlying data may be changing, but the container is new every time regardless. The WordPress news article gives the canonical example:

const { tagNames } = useSelect( ( select ) => {
  const tags = select( 'taxonomies' ).getTags();
  return { tagNames: tags.map( ( t ) => t.name ) };
}, [] );

The article explains the mechanism: “every invocation of the callback calls .map on the tags array, and every such call returns a new array instance. Even though the raw tags array in the store hasn’t changed, and the returned array is semantically the same, the returned array is not equal ( === ) to the previous value. It is therefore detected as a change that needs to trigger a re-render.”

The distinction between Shape A and Shape B is not cosmetic. Shape A is fixed by hoisting a constant. Shape B is fixed by moving the transformation outside the hook. Applying the Shape A fix to Shape B leaves the loop in place; applying the Shape B fix to Shape A adds unnecessary memoization.

Root cause: referential identity, not data identity

The root cause is that the selector callback is a function that runs on every relevant store change, and its return value is compared by identity at the container level. If the callback constructs a new object or array on every invocation, the comparison always reports a change.

This is a referential-identity bug in the selector, not a defect in useSelect or in the block editor store. The hook’s contract is to re-render when the returned value is not shallowly equal. The author’s job is to make the return value stable when the underlying data is stable.

There is a second contributing factor: the dependency array. The WordPress news article notes that if the dependency array has not changed since the previous call, useSelect “can reuse the previous function and the previous value computed by it.” Omitting the dependency array, or passing a new array literal on every render, defeats that reuse. The dependency array is optional only for backward compatibility; the article recommends always specifying it.

Fix: stable selector patterns, in order of preference

1. Return primitives directly

If you need one value, return that value, not an object wrapping it. A string, number, or boolean is compared by value, so a new primitive with the same content is equal.

const hasTitle = useSelect( ( select ) => {
  return select( 'core/editor' ).getEditedPostAttribute( 'title' ).length > 0;
}, [] );

The WordPress news article makes the same point about derived booleans: calculating the boolean inside the callback means the component re-renders only when the boolean actually changes.

2. Hoist constant empty defaults

For Shape A, define the empty value once, outside the component or at module scope, and return that same reference:

const EMPTY_BLOCKS = [];

const { reusableBlocks } = useSelect( ( select ) => {
  const blocks = select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
  return { reusableBlocks: blocks ?? EMPTY_BLOCKS };
}, [] );

This is the fix Gutenberg applied in PR #46117. The constant is stable, so the shallow comparison sees the same array reference when the store still has no records.

3. Return the raw store value and transform outside the hook

For Shape B, do not call .map, .filter, or object construction inside the callback. Select the raw value, then transform it with useMemo outside:

const { tags } = useSelect( ( select ) => ( {
  tags: select( 'taxonomies' ).getTags(),
} ), [] );

const tagNames = useMemo( () => {
  return tags.map( ( t ) => t.name );
}, [ tags ] );

The WordPress news article states the result: “This will cause a re-render only when the raw tags really change.” The raw array from the store is stable until the store changes, so the useMemo dependency is stable, and the derived array is recomputed only when needed.

4. Return an object of primitives when you need multiple values

If you genuinely need several values from the same store, return an object whose properties are primitives. The object container is new, but shallow equality compares the properties, and primitives compare by value:

const { title, blockCount } = useSelect( ( select ) => {
  const editor = select( 'core/editor' );
  return {
    title: editor.getEditedPostAttribute( 'title' ),
    blockCount: select( 'core/block-editor' ).getBlockCount(),
  };
}, [] );

The WordPress news article notes that returning an object to be destructured is “guaranteed to be always safe” for value types, whereas returning a custom class instance can fail shallow comparison because private fields are not enumerable. The practical rule: keep the container’s properties to primitives, and do not put arrays or objects inside it unless they are stable references.

5. Always pass the dependency array

The dependency array is what lets useSelect reuse the previous callback and value when the inputs have not changed. Pass [] for a selector that depends on nothing, and pass the actual dependencies when the callback closes over props or state:

const block = useSelect( ( select ) => {
  return select( 'core/block-editor' ).getBlock( clientId );
}, [ clientId ] );

Without the dependency array, the callback is a new function on every render, and useSelect has no way to know that it would return the same value for the same inputs.

The event-handler exception

If you only need the selector inside an event handler, you do not need a subscription at all. The WordPress news article describes a non-reactive form:

const { getTitle } = useSelect( 'site' );

This returns the set of selectors without establishing a subscription. The article notes that this is useful “not only in event handlers, but also in other handlers triggered by external events, like timers or promise resolutions.” Using the reactive form for a value that is only read on click creates a subscription that does nothing useful and can contribute to unnecessary re-renders.

Verification

Do not guess whether the selector is stable. Verify it.

Enable the development warning

Gutenberg PR #53666 added a development-mode warning for useSelect hooks that return a new object or array reference on each mapSelect call. The PR describes the mechanism: “Call the mapSelect function again and compare the returned results with the previous call.” The warning is only displayed in development mode. If it fires, the callback is returning a new reference; do not suppress it. Bisect the return expression until the unstable branch is isolated.

Check the React error

If the console shows The result of getSnapshot should be cached, the selector is returning a new value on every call. The React documentation’s troubleshooting section identifies the cause directly: getSnapshot returns a new object every time it is called. The fix is to return the same snapshot when the store has not changed.

Count renders before and after

Add a temporary render counter to the component and dispatch an unrelated action, such as opening a sidebar or typing in a non-editor input. If the counter increments on every dispatch, the selector is unstable. After applying the fix, the counter should increment only when the selected data actually changes.

const renderCount = useRef( 0 );
renderCount.current += 1;
console.log( 'renders', renderCount.current );

This is a diagnostic, not a permanent addition. Remove it once the selector is stable.

Inspect the selector return value directly

In the browser console, you can call the selector outside React and compare references:

const a = wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
const b = wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' );
console.log( a === b );

If the store has not changed, this should log true. If it logs false, the selector itself is returning a new reference, and the fix belongs in the selector or in how you consume it. This check does not prove your useSelect callback is stable, but it separates store-level instability from callback-level instability.

What not to do

Do not wrap the entire callback in useCallback and assume the problem is solved. The callback identity is not the comparison target; the return value is. A stable callback that returns a new object still triggers the loop.

Do not use deep equality as a workaround. The comparison is shallow by design, and deep equality on every store change is expensive. Fix the reference, not the comparison.

Do not disable the development warning. It is the cheapest diagnostic available, and suppressing it hides the next occurrence of the same bug.

FAQ

Is this a bug in useSelect?

No. useSelect re-renders when the returned value is not shallowly equal to the previous one. That is its documented behavior. The bug is in the selector callback returning a new container on every call.

Why does returning an object literal cause a loop but returning a string does not?

Shallow equality compares the fields of an object, but the object container itself is a new reference. A string is a primitive and compares by value, so two identical strings are equal. An object literal with identical properties is still a new object.

Does useMemo fix every case?

No. useMemo is appropriate for Shape B, where the transformation is expensive and the raw store value is stable. For Shape A, where the problem is a new empty default, hoisting a constant is simpler and more direct. Using useMemo to recreate the same empty array on every render does not help.

What about selecting from multiple stores in one callback?

The WordPress news article recommends doing all selections from the same store in one callback to avoid multiple subscriptions. When selecting from multiple stores, the same stability rules apply to the returned value: keep the container’s properties to primitives or stable references.

Where can I read the primary sources?

The WordPress Developer News article How to work effectively with the useSelect hook covers the shallow-equality condition, the .map example, the useMemo fix, and the non-reactive form. The Gutenberg PRs #46117 and #53666 document the empty-array default bug and the development warning. React’s useSyncExternalStore reference documents the snapshot caching requirement and the infinite-loop error.

If you are still tracing why a fresh install shows an empty editor or a blank front end, the failure is usually earlier in the stack than useSelect; this checklist for a new WordPress site that says nothing found is the place to start before blaming the data layer.