You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
eslint-config-next 16 introduced the react-hooks/set-state-in-effect rule, which flags setState calls made synchronously inside a useEffect body. This pattern produces cascading renders, makes data flow harder to reason about, and is almost always a sign that the state can be derived, lifted, or assigned during the triggering event instead.
The rule is currently set to "warn" in eslint.config.mjs:9-11 so it doesn't gate CI:
// TODO: address these new react-hooks rules from eslint-config-next 16// See: https://react.dev/learn/you-might-not-need-an-effect"react-hooks/set-state-in-effect": "warn","react-hooks/immutability": "warn",
This issue tracks the cleanup of all current set-state-in-effect warnings so the rule can graduate to "error".
Scope
16 unique sites across 12 files. (Category A ended up with 6 sites, not 7 — one entry moved to Category D; see below.) Grouping by remedy makes the work tractable — most of these are textbook patterns from React's you-might-not-need-an-effect docs.
Category A — Reset-on-prop-change (use key prop or derived state) — ✅ complete
When a parent prop changes and a child resets its local state to match, the React-recommended fix is for the parent to give the child a different key prop so it remounts naturally — no effect required.
Line numbers below were refreshed against dev on 2026-08-19. Several had drifted since this issue was written.
Dependency on #224. The one site already fixed here was fixed by the key={iri} remount, and #224 is scoped to revisit that remount (evaluating keepPreviousData + manual reset vs. Suspense vs. view transitions). If #224 removes the remount, PropertyDetailPanel.tsx:260 returns and Category A grows back to 7. Settle #224's direction before or alongside this category — the two are working the same mechanism from opposite ends.
components/editor/PropertyDetailPanel.tsx:260 — reset edit state when propertyIri changes — already resolved by commit c22733a (PR feat: preserve entity selection across viewer/editor modes #104, merged 2026-05-02), which applied exactly this category's remedy: key={iri} on all three detail panels in both editor layouts, dropping the redundant navigate-away effects. Landed ~1 h after this issue was filed, so it was never reflected here. See the Eliminate detail-panel flash when switching entities in the tree #224 caveat below.
Category C — Server-data-derived (overlaps with #89) — ✅ complete
State mirrored from a query response. Best fix is to derive the value via React Query's select option, or compute it in render rather than mirroring into local state.
Category D — One-shot init / event-driven UI (move to handler or useState initializer)
Work that runs once on mount or in response to a discrete event should live in a useState lazy initializer or in the handler that triggered it, not in an effect.
app/projects/[id]/editor/page.tsx:311 — setOntologyNamespace / setOntologyPrefix / setIriPattern / setNextNumeric from a one-shot prefix-map scan, latched by iriPatternDetectedRef and reset only after a save — moved here from Category A (it is a one-shot init, not a reset-on-prop-change). Needs a decision before implementation: removing the effect means choosing between keeping the latch and deriving live with useMemo, which changes when iriPattern / nextNumeric update and therefore what IRIs get minted.
Recommended order
Category A first (7 sites). Largest cluster, single canonical fix (key prop or derivation), and exercises the test suite hardest because the affected components have substantial coverage.
Category D (4 sites). Small, mostly mechanical fixes; gets the warning count down quickly.
Category B (2 sites). useSyncExternalStore migration is more involved but well-isolated.
Context
eslint-config-next16 introduced thereact-hooks/set-state-in-effectrule, which flagssetStatecalls made synchronously inside auseEffectbody. This pattern produces cascading renders, makes data flow harder to reason about, and is almost always a sign that the state can be derived, lifted, or assigned during the triggering event instead.The rule is currently set to
"warn"ineslint.config.mjs:9-11so it doesn't gate CI:This issue tracks the cleanup of all current
set-state-in-effectwarnings so the rule can graduate to"error".Scope
16 unique sites across 12 files. (Category A ended up with 6 sites, not 7 — one entry moved to Category D; see below.) Grouping by remedy makes the work tractable — most of these are textbook patterns from React's you-might-not-need-an-effect docs.
Category A — Reset-on-prop-change (use
keyprop or derived state) — ✅ completeWhen a parent prop changes and a child resets its local state to match, the React-recommended fix is for the parent to give the child a different
keyprop so it remounts naturally — no effect required.components/editor/AddEntityDialog.tsx:96— reset dialog state whenopenflips — PR fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365 (state moved into an inner form that Radix unmounts on close)components/editor/AddEntityDialog.tsx:120—setIri(generateIri(label))derived fromlabel— PR fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365 (nowiriOverride ?? generatedIri)— already resolved by commitcomponents/editor/PropertyDetailPanel.tsx:260— reset edit state whenpropertyIrichangesc22733a(PR feat: preserve entity selection across viewer/editor modes #104, merged 2026-05-02), which applied exactly this category's remedy:key={iri}on all three detail panels in both editor layouts, dropping the redundant navigate-away effects. Landed ~1 h after this issue was filed, so it was never reflected here. See the Eliminate detail-panel flash when switching entities in the tree #224 caveat below.components/editor/PropertyDetailPanel.tsx:293— restored-draft init when bothrestoredDraftandpropertyIriare present — PR fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365 (seeded during render;useEntityAutoSavenow exposesrestoredDrafton the first render). PR fix: make annotation placeholder rows respect property cardinality (closes #203) #273 edits lines inside this effect and will need to rebase onto it.components/editor/DeleteImpactAnalysis.tsx:32— reset state whenentityIri/projectIdchange — PR fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365 (React Query +key={entityIri}; also fixed Delete is permanently disabled for entities with no references #364)app/page.tsx:75—setNextPageError(null)whenfilter/debouncedSearchchange — PR fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365 (error tagged with its list identity and derived during render)Category B — Browser-API subscriptions (use
useSyncExternalStore) — ✅ resolved (1 fixed, 1 removed by #154)External stores (matchMedia, websockets) belong in
useSyncExternalStore, notuseEffect+setState.components/editor/TurtleEditor.tsx:110—setSystemThemefromwindow.matchMedia("(prefers-color-scheme: dark)")— PR fix: read prefers-color-scheme via useSyncExternalStore (Category B, #200) #367 (newusePrefersDarkModehook built onuseSyncExternalStore)lib/hooks/useCollaborationStatus.ts:132— websocket connect/disconnect lifecycle — no work needed here: #154 deletes this implementation. Issue #140 is that the hook pointed at the lint endpoint; refactor: decouple useCollaborationStatus from lint WebSocket endpoint #154 replaces the whole body with a stub that has nouseState/useEffect, so the warning goes with it. Wrapping it in a store first would conflict with refactor: decouple useCollaborationStatus from lint WebSocket endpoint #154 for no benefit.Category C — Server-data-derived (overlaps with #89) — ✅ complete
State mirrored from a query response. Best fix is to derive the value via React Query's
selectoption, or compute it in render rather than mirroring into local state.lib/context/BranchContext.tsx:119—setCurrentBranchafterresponsearrives — PR fix: derive server-backed state instead of mirroring it (Category C, #200) #369 (currentBranchis now auseMemoover the query response and the visitor's explicit selection)lib/context/BranchContext.tsx:152—setInitialBranchHandled(true)after init — PR fix: derive server-backed state instead of mirroring it (Category C, #200) #369 (flag removed entirely; precedenceselectedBranch ?? initialBranch ?? storedBranchexpresses it directly)app/projects/[id]/suggestions/page.tsx:88— manualsetIsLoadingSessions/setSessions/setSessionsErrorfrom a fetch — PR fix: derive server-backed state instead of mirroring it (Category C, #200) #369 (newuseSuggestionSessionsReact Query hook; Migrate manual data fetching to React Query hooks #89's migration had already landed via PR feat: migrate data fetching to React Query hooks #105, this was the leftover)Category D — One-shot init / event-driven UI (move to handler or
useStateinitializer)Work that runs once on mount or in response to a discrete event should live in a
useStatelazy initializer or in the handler that triggered it, not in an effect.app/auth/error/page.tsx:39— PR fix: eliminate setState-in-effect anti-patterns (Category D, #200) #362 —setRetryCount((c) => c + 1)inside countdown tick; move into the interval handler that also updatessetCountdownapp/page.tsx:31— PR fix: eliminate setState-in-effect anti-patterns (Category D, #200) #362 — one-shot auth-default-applied flag with ref guard; use auseStatelazy initializer that readsisAuthenticatedonceapp/settings/page.tsx:342— PR fix: eliminate setState-in-effect anti-patterns (Category D, #200) #362 —setHighlightedSetting(hash)for hash-scroll spy; move into the hash-listener handlercomponents/editor/shared/EntityTreeToolbar.tsx:52— PR fix: eliminate setState-in-effect anti-patterns (Category D, #200) #362 —setShowTip(true)fromlocalStorageread on mount; useuseState(() => !localStorage.getItem(TIP_DISMISSED_KEY))lazy initializerapp/projects/[id]/editor/page.tsx:311—setOntologyNamespace/setOntologyPrefix/setIriPattern/setNextNumericfrom a one-shot prefix-map scan, latched byiriPatternDetectedRefand reset only after a save — moved here from Category A (it is a one-shot init, not a reset-on-prop-change). Needs a decision before implementation: removing the effect means choosing between keeping the latch and deriving live withuseMemo, which changes wheniriPattern/nextNumericupdate and therefore what IRIs get minted.Recommended order
keyprop or derivation), and exercises the test suite hardest because the affected components have substantial coverage.useSyncExternalStoremigration is more involved but well-isolated.Done criteria
editor/page.tsx:311, which needs a latch-vs-live-derivation decision.devemits 15 warnings; fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365 → 10, fix: read prefers-color-scheme via useSyncExternalStore (Category B, #200) #367 → 9, fix: derive server-backed state instead of mirroring it (Category C, #200) #369 → 6, fix: eliminate setState-in-effect anti-patterns (Category D, #200) #362 → 2, refactor: decouple useCollaborationStatus from lint WebSocket endpoint #154 → 1.react-hooks/set-state-in-effectupgraded from"warn"to"error"ineslint.config.mjsreact-hooks/immutabilityevaluated and either fixed or kept at"warn"with a documented reasonset-state-in-effectwarnings introduced (CI now enforces)Related
key={iri}remount that resolved one Category A site)c22733a)usePrefersDarkMode), stacked on fix: eliminate setState-in-effect anti-patterns (Category A complete, #200) — fixes #364 #365