SharePointVault ~/gotchas/spfx ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ rules-of-hooks-false-positive-from-jsx-chain.md
sharepoint-vault›gotchas›spfx›rules-of-hooks-false-positive-from-jsx-chain.mdhistoryedit on GitHub
● GOTCHAspfx· 5 min read

rules-of-hooks false-positive — a complex JSX && chain flags the wrong hook

applies to: SharePoint Online (SPFx, React 17, eslint-plugin-react-hooks)reviewed: 2026-09-24#spfx#react#eslint#debugging

When react-hooks/rules-of-hooks says a hook is "called conditionally" but that hook plainly isn't, stop looking at the hook. A long && chain with truthiness-narrowing in your JSX return (e.g. {a && b && x && x.length === 0 && (<…/>)}) can break the plugin's control-flow analysis and make it blame an earlier, unrelated hook. Extract the condition into a named const and the error moves.

Když react-hooks/rules-of-hooks hlásí „hook je volán podmíněně“, ale ten hook zjevně podmíněný není, přestaň se dívat na hook. Dlouhý && řetěz s truthy-narrowingem v JSX ({a && b && x && x.length === 0 && (<…/>)}) rozbije analýzu control-flow pluginu a označí dřívější, nesouvisející hook. Vytáhni podmínku do pojmenované const a chyba zmizí.

Symptom

ESLint fails the build with:

React Hook React.useCallback is called conditionally. React Hooks must be called in the exact same order in every component render. (react-hooks/rules-of-hooks)

…pointing at a useCallback that is defined at the top of the component, before any early return, with nothing conditional around it. tsc --noEmit is clean. Reverting the "obvious" suspects (a new useState, a new useEffect, a derived const) changes nothing. git stash of that one file makes the error vanish — so it is your edit, just not the part you keep staring at.

Cause

The trigger is a piece of JSX you added to the render output (often after an early return), of the shape:

return (
  <div>
    {/* … */}
    {user && user.role === 'agent' && scopeIds && scopeIds.length === 0 && (
      <Banner>No companies assigned.</Banner>
    )}
  </div>
);

That's a four-term && chain where scopeIds && scopeIds.length does truthiness-narrowing on a number[] | null. eslint-plugin-react-hooks builds a control-flow graph of the component to verify every hook runs unconditionally. A sufficiently complex boolean expression in the returned JSX can confuse that graph, and the rule then reports a preceding useCallback/useMemo as conditionally called — the real culprit (the JSX chain) is never named. It is not about hook position, not about the early return, and not about the specific variable.

Fix

Lift the condition out of the JSX into a named boolean const, and keep the JSX trivial:

const agentHasNoCompanies =
  !!user && user.role === 'agent' && scopeIds !== null && scopeIds.length === 0;

return (
  <div>
    {/* … */}
    {agentHasNoCompanies && <Banner>No companies assigned.</Banner>}
  </div>
);

The const sits after the hooks and before the return; the plugin sees a single simple identifier in the JSX and stops mis-attributing.

Debugging heuristic: when rules-of-hooks blames a hook that is obviously unconditional, don't bisect the hooks — bisect the render. Look for a complex &&/ternary expression added in the same batch (even below an early return). git stash <file> confirms HEAD is clean faster than reverting hunk by hunk, and hand-reverted intermediate states mislead more than they help — reset to a clean HEAD and re-add in small blocks, running eslint after each.

Second trigger: component size — and here extracting conditions does not help

Added 2026-07-31. The same false-positive has a second cause, and the fix above makes no difference to it. Adding two cards to a ~960-line component holding 20+ hooks flagged 30 hooks that had nothing to do with the new code. Six rounds of the prescribed remedy — lifting conditions into const, flattening nested ternaries, moving derived values below the hooks — only shuffled the count (35 → 3 → 30 → 30), and one round increased it, which reads as "it's getting better" and isn't.

What fixed it, first try, was extracting the new part into its own component:

// Before: one component, 20+ hooks, the new cards inline in a 400-line return
// After:  <MfaCards user={user} … />  — its own hooks, props instead of closure

How to tell the two apart: if lifting conditions doesn't help, or the error count oscillates between runs, the trigger isn't the shape of an expression — it's the volume of the component. Stop tuning expressions and split the component. That is also the better design: the extracted part usually turns out to be a separate concern with its own state and its own loading.

Third trigger: the recommended fix itself — and why only bisection finds it

Added 2026-08-28. A confirmation dialog was added to a ~1650-line component: 27 errors. Extracting the whole feature into its own hook (about 130 lines out of the component, the "split the component" remedy above) brought it down to 23 — so volume was not the whole story either. Bisecting from a clean HEAD, block by block, pinned it on a single line, and it was the simplification:

// clean
onSendDocs={target && target.found ? () => send(selected) : undefined}

// 24 errors, all in unrelated hooks 300 lines above
const canSend = !!target && !!target.found;      // ← the const itself is fine
onSendDocs={canSend ? () => send(selected) : undefined}   // ← using it HERE is not

The const may exist and be used elsewhere (in a props object, in another branch) without any problem. It breaks only when it appears in that ternary in the JSX. Putting the inline condition back: zero errors.

So the rule "lift the condition into a named const" is not reliable — the plugin trips over a specific expression, and which variant trips it cannot be predicted; here the simpler one did. The only method that converges quickly:

  1. git checkout -- <file> and confirm HEAD lints clean.
  2. Re-apply your change in small blocks, running npx eslint <file> after each block.
  3. On the guilty block, try variants one at a time.

Guessing "what might be upsetting it" reliably burns dozens of rounds. Splitting the component is still worth doing (better design, smaller file) — just don't expect it to clear the error.

Fourth trigger: ternaries in the initial values of hooks

Added 2026-09-24. A ~640-line wizard gained a "resume" mode, which meant five initial values like useState(resume ? resume.campaign.Title : ''). The plugin then flagged 26 hooks that come after them as "called conditionally". In ESLint's code-path analysis every ternary and every &&, || or ?? in the component body is a fork — even inside a hook's argument, since only a nested function gets its own code path (code-path analyzer). The rule reasons about paths through the whole component, so forks placed after the hooks matter too — the first trigger above is JSX in the return.

What cleared it on the first try: compute the defaults in a pure function outside the component, so the forks leave the component's own code path.

// outside the component
function initialContent(resume?: IResume | null) {
  const c = resume ? resume.campaign : null;
  return c ? { name: c.Title, subject: c.Subject || '' } : { name: '', subject: '' };
}

// inside
const init = useMemo(() => initialContent(resume), [resume]);
const [name, setName] = useState(init.name);
const [subject, setSubject] = useState(init.subject);

Note this is a lint failure only — the code runs correctly either way. But build --production runs lint as an error, so it blocks the release.

Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-09-24last updated: 2026-09-24historyedit on GitHubview source