Skip to content

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

Closed

Description

@re5et

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

Summary

useColorScheme's subscription effect has no dependency array:

// 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):

     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)

Activity

  1. timheilman commented on Aug 27, 2026

    @timheilman

    Independently reproduced this via a completely different real-world code path -- worth noting since it confirms the mechanism isn't specific to any one consumer of useColorScheme().

    I hit this as a stale background color on @expo/ui's web BottomSheet (built on vaul), which calls useColorScheme() internally to pick its own background. Reopening the sheet after toggling prefers-color-scheme while it was closed would sometimes show the pre-toggle color, while ordinary React content inside the sheet (also driven by a separate useColorScheme()-backed theme context in my app) re-themed correctly on the same reopen -- i.e. exactly the "some subset of useColorScheme() instances get stranded, others don't" pattern described here, not a full app-wide failure to update.

    I bisected it down with a minimal repro (https://github.com/timheilman/expo-ui-bottomsheet-theme-repro) and found the second, independent useColorScheme() subscriber was the necessary ingredient -- a single subscriber alone never reproduced it, matching the mid-dispatch-removal mechanism described above exactly.

    Applied the same [] fix locally via pnpm patch and it resolves it completely.

    – also drafted by Claude (AI)

  2. huytdps13400 commented on Aug 30, 2026

    @huytdps13400
    Contributor

    I opened #2853 with the one-line effect dependency fix and a regression test that verifies rerenders do not remove/re-add the Appearance subscription. The test failed before the fix with 2 registrations after one rerender and passes afterward. Full Flow, format, lint, DOM/Node tests, react-native-web build, and a two-subscriber Chromium runtime check all passed.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions