Skip to content

perf(useMedia): read live matchMedia on first client render via useSyncExternalStore#8001

Merged
mattcosta7 merged 14 commits into
mainfrom
copilot/derive-state-from-effects
Jul 7, 2026
Merged

perf(useMedia): read live matchMedia on first client render via useSyncExternalStore#8001
mattcosta7 merged 14 commits into
mainfrom
copilot/derive-state-from-effects

Conversation

@mattcosta7

@mattcosta7 mattcosta7 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Overview

Status: WIP / draft — not ready to merge. Opening early for direction + discussion.

The react-hooks/set-state-in-effect lint rule (from eslint-plugin-react-hooks@7.1.1, the "you might not need an effect" check) flags effects whose only job is to compute state. This PR is a full accounting of every suppression of that rule in the repo: the genuinely-derivable effects are rewritten to compute state during render (or via an external store), and every remaining suppression is either documented in code as an intentional effect or listed below as deferred demo/test cleanup.

No public API changes.

Changelog

Changed

  • useMedia now reads its value via useSyncExternalStore instead of useState + useEffect, eliminating the extra render on mount and the SSR/hydration setState. On the client, the first render returns the live matchMedia value rather than defaultState (which now serves purely as the SSR/hydration snapshot).
  • ToggleSwitch hides the loading status label via render-time derivation; only the delayed reveal remains in an effect (it needs a timer).
  • SelectPanel resets the single-select-modal intermediateSelected value by tracking the previous open state during render instead of in an effect.
  • ValidationAnimationContainer (internal) mounts its content via render-time derivation; un-mounting stays deferred to onAnimationEnd so the exit animation still plays.
  • Documented the remaining intentional effects (DOM reads / side effects) inline so each suppression states why it cannot be derived.
  • Test-only: the useMedia test's matchMedia mock now updates matches on change(), mirroring real MediaQueryList behavior (the old mock only fired the event).

New

None.

Removed

None.

Rollout strategy

  • Patch release

Complete set-state-in-effect inventory

Repo-wide before this PR: 22 suppressions (11 in component/hook source, 11 in stories + storybook data).

✅ Fixed — genuinely derivable (4)

Site Rewrite
hooks/useMedia.ts useSyncExternalStore (tear-free, no redundant initial render)
ToggleSwitch.tsx Render-time derive (hide-label branch); timer stays in effect
SelectPanel.tsx (intermediateSelected) Adjust-during-render on open transition
internal/.../ValidationAnimationContainer.tsx Render-time derive; exit deferred to onAnimationEnd

🟡 Intentional effects — now documented in code (7)

These trip the rule but are necessary effects (committed-DOM reads or consumer side effects). Each suppression now carries a one-line rationale.

Site Why it stays an effect Cleanly splittable later?
TreeView.tsx (setSubTreeLabel) Reads committed DOM via getAccessibleName No
LabelGroup.tsx (hideChildrenAfterIndex) Reads rendered children from the DOM 🔜 numeric branch could derive from child indices
Autocomplete/AutocompleteMenu.tsx (sort-on-close) Shares the effect with the onOpenChange callback 🔜 split sort (derive) from onOpenChange (effect)
Dialog.tsx (setHasRendered) Commit-timing focus hack No
experimental/SelectPanel2/SelectPanel.tsx (setLabelText) Reads label textContent from committed DOM No
SelectPanel.tsx (no-items announce) Commit-timing a11y live-region announcement No
SelectPanel.tsx (first-open fetch) Fires a consumer callback (side effect) No

🟦 Deferred — demo/test code (11, not touched)

Same pattern in stories + storybook data. Lower value and risk of changing story/VRT behavior, so deferred per "clean these separately":

  • DataTable/storybook/data.ts (1)
  • experimental/SelectPanel2/SelectPanel.examples.stories.tsx (1)
  • FormControl/FormControl.features.stories.tsx (2)
  • SelectPanel/SelectPanel.examples.stories.tsx (4)
  • SelectPanel/SelectPanel.features.stories.tsx (3)

Note

Related but not flagged by the rule: TreeView's setIsSubTreeEmpty effect writes to a parent's ItemContext setter. The rule only flags same-component state, and this one genuinely can't move to render (cross-component update), so it correctly stays an effect.

Testing & Reviewing

All functional changes are behavior-preserving refactors. Validated locally:

  • useMedia (4), ToggleSwitch (16), FormControl (39), CheckboxGroup (9), RadioGroup (7), SelectPanel (152) unit tests pass.
  • tsc --noEmit and eslint pass on the changed files.

Please pay special attention to:

  • useMedia — the client-first-render value change (live value vs defaultState). Mirrors what useIsMacOS already does.
  • SelectPanel — the reset now keys on open transitions (matching the original comment). The previous effect also re-ran on selected/isSingleSelectModal mid-open; not expected for a single-select modal (selection commits on close), but worth confirming.

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Added/updated previews (Storybook)
  • Changes are SSR compatible
  • Tested in Chrome (Vitest browser project)
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge
  • (GitHub staff only) Integration tests pass at github/github-ui

Important

The 🟦 deferred demo/test items and the 🔜 splittable source effects (LabelGroup numeric branch, AutocompleteMenu sort-on-close) are intentionally left for separate, individually-tested PRs.

Rewrite four effects flagged by react-hooks/set-state-in-effect to derive
state during render or via useSyncExternalStore:

- useMedia: use useSyncExternalStore (tear-free, no redundant initial render)
- ToggleSwitch: hide loading label via render-time derivation; keep only the
  delayed reveal in an effect
- SelectPanel: reset intermediateSelected by tracking previous open state
- ValidationAnimationContainer: mount via render-time derivation

Also make the useMedia matchMedia mock reflect real MediaQueryList behavior
(matches updates on change).
@changeset-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 08cff0d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Jun 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Action required

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Check the integration testing docs for step-by-step instructions. Or, apply the integration-tests: skipped manually label to skip these checks.

To publish a canary release for integration testing, apply the Canary Release label to this PR.

Account for every remaining react-hooks/set-state-in-effect suppression in
component/hook source. These are necessary effects (committed-DOM reads or
consumer side effects), so add an inline rationale to each rather than a bare
disable:

- TreeView: getAccessibleName reads committed DOM
- LabelGroup: hideChildrenAfterIndex reads DOM children (numeric branch is a
  possible follow-up derivation)
- AutocompleteMenu: sort-on-close shares the effect with onOpenChange
- Dialog: commit-timing focus hack
- SelectPanel2: reads label textContent from DOM
- SelectPanel: no-items announce (commit timing) and first-open fetch (callback)

Demo/test suppressions (stories, storybook data) are left for separate cleanup.
@mattcosta7

Copy link
Copy Markdown
Contributor Author

@copilot resolve the merge conflicts in this pull request

Resolve conflicts from Add eslint-plugin-react-you-might-not-need-an-effect (#8000):
- Keep useSyncExternalStore implementation in useMedia.ts (our refactor)
- Keep render-time derivation in ValidationAnimationContainer.tsx (our refactor)
- Keep render-time derivation in SelectPanel.tsx intermediateSelected (our refactor)
- Keep render-time derivation in ToggleSwitch.tsx; add no-event-handler suppression
- Merge descriptive comments with new react-you-might-not-need-an-effect suppressions
  in Dialog.tsx, AutocompleteMenu.tsx, SelectPanel.tsx, and TreeView.tsx

Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com>

Copilot AI commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Done — merged main into this branch and resolved all 7 conflicts (commit 590aced):

File Resolution
useMedia.ts Kept our useSyncExternalStore refactor
ValidationAnimationContainer.tsx Kept our render-time derivation
ToggleSwitch.tsx Kept our render-time !loading branch; added no-event-handler suppression for the timer effect
Dialog.tsx Kept descriptive comment; added no-derived-state suppression
AutocompleteMenu.tsx Kept descriptive comment; added no-derived-state suppression
SelectPanel.tsx (×2) Kept prevOpen render-time pattern; kept descriptive comment + added no-adjust-state-on-prop-change suppression
TreeView.tsx Kept descriptive comment; added no-chain-state-updates suppression

Lint is clean on all resolved files.

@mattcosta7 mattcosta7 self-assigned this Jun 17, 2026
@mattcosta7 mattcosta7 marked this pull request as ready for review June 17, 2026 15:24
Copilot AI review requested due to automatic review settings June 17, 2026 15:24
@mattcosta7 mattcosta7 requested a review from a team as a code owner June 17, 2026 15:24
@mattcosta7 mattcosta7 requested a review from jonrohan June 17, 2026 15:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This draft PR aims to eliminate suppressions of the react-hooks/set-state-in-effect lint rule by replacing “compute state in an effect” patterns with render-time derivation (or external-store reads), while documenting the remaining intentional effects that must stay as effects (DOM reads, consumer side effects, commit-timing behavior).

Changes:

  • Refactors useMedia to use useSyncExternalStore (SSR-safe snapshot + live client snapshot) and updates its tests to better mirror real MediaQueryList behavior.
  • Moves several previously-effect-driven state adjustments to render-time derivation in ToggleSwitch, SelectPanel, and ValidationAnimationContainer.
  • Adds inline rationale comments for remaining intentional effect-driven state updates (TreeView, LabelGroup, AutocompleteMenu, Dialog, SelectPanel2, SelectPanel).
Show a summary per file
File Description
packages/react/src/TreeView/TreeView.tsx Documents why getAccessibleName requires a committed-DOM read in an effect.
packages/react/src/ToggleSwitch/ToggleSwitch.tsx Moves “hide loading label” to render-time derivation; keeps delayed reveal in an effect.
packages/react/src/SelectPanel/SelectPanel.tsx Reworks intermediate single-select modal reset logic to run on open transitions during render; adds effect rationale comments.
packages/react/src/LabelGroup/LabelGroup.tsx Documents why DOM-derived truncation must run in an effect.
packages/react/src/internal/components/ValidationAnimationContainer.tsx Mounts content via render-time derivation; unmount remains animation-driven.
packages/react/src/hooks/useMedia.ts Switches to useSyncExternalStore for tear-free matchMedia reading and SSR snapshot handling.
packages/react/src/hooks/tests/useMedia.test.tsx Improves matchMedia mock so .matches reflects the latest change() updates.
packages/react/src/experimental/SelectPanel2/SelectPanel.tsx Documents committed-DOM requirement for label text reads in an effect.
packages/react/src/Dialog/Dialog.tsx Documents commit-count focus timing hack as intentionally effect-driven.
packages/react/src/Autocomplete/AutocompleteMenu.tsx Documents why close-time sort remains effect-driven due to coupling with onOpenChange.
.changeset/derive-state-from-effects.md Adds patch changeset describing the refactor.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment thread packages/react/src/SelectPanel/SelectPanel.tsx Outdated
Comment thread .changeset/derive-state-from-effects.md Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@mattcosta7 mattcosta7 added Canary Release Apply this label when you want CI to create a canary release of the current PR and removed status: wip labels Jul 7, 2026
@github-actions github-actions Bot temporarily deployed to storybook-preview-8001 July 7, 2026 16:44 Inactive
Drop the overlapping ToggleSwitch/SelectPanel/ValidationAnimationContainer
changes (handled by #8024/#8025/#8023) and the comment-only edits to
Autocomplete/Dialog/LabelGroup/TreeView/SelectPanel2. Retarget the changeset
to the useMedia change only.
@mattcosta7 mattcosta7 changed the title Derive state during render instead of effects (react-hooks/set-state-in-effect) perf(useMedia): read live matchMedia on first client render via useSyncExternalStore Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Rescoped this PR down to just the useMedia refactor. The previous ToggleSwitch, SelectPanel, and ValidationAnimationContainer changes overlapped with (and in a couple of cases were superseded by) the focused PRs #8024, #8025, and #8023, so they've been dropped here. The comment-only edits to Autocomplete/Dialog/LabelGroup/TreeView/SelectPanel2 were also removed. The changeset was retargeted accordingly. Both review threads remain addressed by the current useMedia code (SSR warning gated on typeof window === 'undefined' with a client-no-warn test).

@mattcosta7 mattcosta7 added the integration-tests: skipped manually Changes in this PR do not require an integration test label Jul 7, 2026
@mattcosta7

Copy link
Copy Markdown
Contributor Author

CI went green in gh/gh-ui, but unused-code flagged on unrelated changes

@mattcosta7 mattcosta7 enabled auto-merge July 7, 2026 21:31
@primer-integration

Copy link
Copy Markdown

⚠️ Integration PR Outdated

This integration PR does not have the latest commit from the primer/react PR.

Integration PR references: 7358e8ce22a000d0e0ce8d203c7c82d3f9360c8d
Latest commit on primer/react PR: 08cff0df04b3a0674b465e13b17eea111e665b6c

Please update the integration PR to reference the latest commit from the primer/react PR before reviewing workflow results.

@mattcosta7 mattcosta7 added this pull request to the merge queue Jul 7, 2026
@github-actions github-actions Bot temporarily deployed to storybook-preview-8001 July 7, 2026 21:38 Inactive
mattcosta7 added a commit that referenced this pull request Jul 7, 2026
Retargets base to main and drops the old #8001 leftovers (useMedia, SelectPanel,
ToggleSwitch, ValidationAnimationContainer, Dialog, TreeView, SelectPanel2),
which are handled by their own focused PRs. Keeps only the Autocomplete close-time
re-sort and LabelGroup numeric-truncation render derivations.
Merged via the queue into main with commit 4edc824 Jul 7, 2026
60 of 61 checks passed
@mattcosta7 mattcosta7 deleted the copilot/derive-state-from-effects branch July 7, 2026 21:42
@primer primer Bot mentioned this pull request Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Canary Release Apply this label when you want CI to create a canary release of the current PR integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm integration-tests: skipped manually Changes in this PR do not require an integration test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants