useColorScheme resubscribes on every render; listeners removed mid-dispatch miss prefers-color-scheme changes

Author: re5etCreated Aug 3, 2026Updated Aug 30, 2026

react-native-web version: 0.21.2 (bug present since the hook's introduction)

Summary

useColorScheme's subscription effect has no dependency array:

javascript
// dist/exports/useColorScheme/index.js
React.useEffect(() => {
  function listener(appearance) {
    setColorScheme(appearance.colorScheme);
  }
  var _Appearance$addChange = Appearance.addChangeListener(listener),
    remove = _Appearance$addChange.remove;
  return remove;
});   // ← no dep array: tears down + re-adds the listener on EVERY render

Because the listener is removed and re-added on every render, a prefers-color-scheme change can strand hook instances permanently:

  1. The MediaQueryList dispatches the change to its listener list.
  2. The first useColorScheme instance in a component receives it and setStates; the component re-renders.
  3. The re-render runs the no-dep effect's cleanup for the component's other useColorScheme instances, removing their listeners mid-dispatch.
  4. Per DOM semantics, listeners removed during dispatch never receive the in-flight event — so those instances keep the stale scheme until the next change (which then strands a different subset).

Any component (or hook composition) that ends up with more than one useColorScheme instance in its render tree hits this. In our app (Surf, a large RN-web product), toggling the OS theme left large, varying portions of the page on the previous palette until a reload, in a way that looked random because it depended on listener registration order.

Empirical confirmation

We instrumented window.matchMedia (counting registrations and per-listener dispatch) and drove prefers-color-scheme flips via Playwright's page.emulateMedia:

  • Unpatched: 39 listeners registered on the single MediaQueryList; only 11 fired on a flip (a stride pattern — exactly the sibling instances of the components whose first listener fired were skipped); 46 mid-dispatch removals. A probe component rendering useColorScheme() twice showed dark and light simultaneously.
  • With the fix below: 39/39 fired, zero mid-dispatch churn, every consumer updated.

Fix

Add the missing dependency array so the subscription survives re-renders (the listener closure only captures the stable setColorScheme):

diff
     var _Appearance$addChange = Appearance.addChangeListener(listener),
       remove = _Appearance$addChange.remove;
     return remove;
-  });
+  }, []);

We're shipping exactly this via patch-package and it fully resolves the issue. Happy to open a PR if useful.

— drafted by Claude (AI)

Source: necolas/react-native-web