Repository: vercel/next.js
Pull request: Remove useSyncExternalStore from useIsDevRendering

useIsDevRendering is a global pending state for router navigations/actions. The idiomatic API for this pattern is useOptimistic.

Previously, this was implemented using useSyncExternalStore, which led to an additional sync render every time a navigation completed. The advantage of useOptimistic is that it gets automatically reverted in the same commit as the transition it's associated with.

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

Changed files:
filename: packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/dev-render-indicator.tsx
patch: @@ -3,35 +3,21 @@
  * Used by the dev tools indicator to show render status
  */
 
-import { useSyncExternalStore } from 'react'
+import { useEffect, useOptimistic } from 'react'
 
-let isVisible = false
-let listeners: Array<() => void> = []
-
-const subscribe = (listener: () => void) => {
-  listeners.push(listener)
-  return () => {
-    listeners = listeners.filter((l) => l !== listener)
-  }
-}
-
-const getSnapshot = () => isVisible
-
-const show = () => {
-  isVisible = true
-  listeners.forEach((listener) => listener())
-}
-
-const hide = () => {
-  isVisible = false
-  listeners.forEach((listener) => listener())
-}
+const optimisticStateSetters = new Set<(boolean: true) => void>()
 
 export function useIsDevRendering() {
-  return useSyncExternalStore(subscribe, getSnapshot)
+  const [isDevRendering, setIsDevRendering] = useOptimistic(false)
+  useEffect(() => {
+    optimisticStateSetters.add(setIsDevRendering)
+    return () => {
+      optimisticStateSetters.delete(setIsDevRendering)
+    }
+  }, [setIsDevRendering])
+  return isDevRendering
 }
 
-export const devRenderIndicator = {
-  show,
-  hide,
+export function setDevRenderIndicatorPending() {
+  optimisticStateSetters.forEach((setter) => setter(true))
 }

filename: packages/next/src/client/components/react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator.tsx
patch: @@ -1,16 +0,0 @@
-import { useEffect, useTransition } from 'react'
-import { devRenderIndicator } from './dev-render-indicator'
-
-export const useSyncDevRenderIndicator = () => {
-  const [isPending, startTransition] = useTransition()
-
-  useEffect(() => {
-    if (isPending) {
-      devRenderIndicator.show()
-    } else {
-      devRenderIndicator.hide()
-    }
-  }, [isPending])
-
-  return startTransition
-}

filename: packages/next/src/client/components/use-action-queue.ts
patch: @@ -1,5 +1,5 @@
 import type { Dispatch } from 'react'
-import React, { use } from 'react'
+import React, { startTransition, use } from 'react'
 import { isThenable } from '../../shared/lib/is-thenable'
 import type { AppRouterActionQueue } from './app-router-instance'
 import type {
@@ -19,6 +19,20 @@ export function dispatchAppRouterAction(action: ReducerActions) {
       'Internal Next.js error: Router action dispatched before initialization.'
     )
   }
+
+  if (process.env.NODE_ENV !== 'production') {
+    // In development, set an pending state on the DevTools indicator
+    // whenever a router action is in progress. This is backed by useOptimistic,
+    // so we don't need to set it back to false; it will be automatically reset
+    // when the transition completes.
+    const setDevRenderIndicatorPending =
+      require('./react-dev-overlay/utils/dev-indicator/dev-render-indicator')
+        .setDevRenderIndicatorPending as typeof import('./react-dev-overlay/utils/dev-indicator/dev-render-indicator').setDevRenderIndicatorPending
+    startTransition(() => setDevRenderIndicatorPending())
+  }
+
+  // NOTE: This is wrapped in a startTransition internally, but to avoid a
+  // refactor hazard we should consider wrapping it here instead.
   dispatch(action)
 }
 
@@ -34,22 +48,7 @@ export function useActionQueue(
   // Ideally, what we'd do instead is pass the state as a prop to root.render;
   // this is conceptually how we're modeling the app router state, despite the
   // weird implementation details.
-  if (process.env.NODE_ENV !== 'production') {
-    const useSyncDevRenderIndicator =
-      require('./react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator')
-        .useSyncDevRenderIndicator as typeof import('./react-dev-overlay/utils/dev-indicator/use-sync-dev-render-indicator').useSyncDevRenderIndicator
-    // eslint-disable-next-line react-hooks/rules-of-hooks
-    const syncDevRenderIndicator = useSyncDevRenderIndicator()
-
-    dispatch = (action: ReducerActions) => {
-      syncDevRenderIndicator(() => {
-        actionQueue.dispatch(action, setState)
-      })
-    }
-  } else {
-    dispatch = (action: ReducerActions) =>
-      actionQueue.dispatch(action, setState)
-  }
+  dispatch = (action: ReducerActions) => actionQueue.dispatch(action, setState)
 
   return isThenable(state) ? use(state) : state
 }