orionus.dev

Hooks called from nowhere

Astro dev logs 'Invalid hook call' every request, build stays clean. Cause: a React island returns null, tripping @astrojs/react's check() probe.

by Full-stack dev. Ukraine.

There’s a class of bug that doesn’t break the page. It only breaks the silence.

Short version, for anyone who arrived here from a search and needs the answer fast.

If npm run dev on an Astro site prints Invalid hook call. Hooks can only be called inside of the body of a function component on every request, and npm run build finishes clean, check every React island for a top-level return null before its JSX.

The check() function inside @astrojs/react/dist/server.js probes each .tsx component by reading vnode["$$typeof"] off whatever it returns. A null return short-circuits that read. The integration disclaims the component. Astro falls through to a renderer path that re-invokes hooks without a React dispatcher. That fallback is what the error message is reporting.

Replace return null with a non-null vnode (return <span hidden aria-hidden="true" /> is enough). The log goes quiet.

Versions where this reproduces: Astro 6.3.4, @astrojs/react 5.0.5, React + ReactDOM 19.2.6, motion 12.39.0.

The rest of this post is how the bug found me.

The signal

The dev server is running. The page renders. Every request, the terminal coughs up the same paragraph:

Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app

Three reasons. None apply.

npm ls react shows one copy, deduped to the floor. Hooks at the top of every body, no conditionals around them. React and ReactDOM both 19.2.6. npm run build finishes clean. The bug only lives in dev, and only in the log, not on the screen.

The message is lying. The question is what it’s lying about.

The suspect

BlackHole.tsx. A client:load island that animates a fixed SVG sphere in the top-right corner of the site. Textbook React:

import { useEffect, useState } from 'react';
import { motion, useReducedMotion } from 'motion/react';

export default function BlackHole() {
  const reduce = useReducedMotion();
  const [resume, setResume] = useState<{ from: number; remaining: number } | null>(null);

  useEffect(() => {
    setResume({ from: 0.3, remaining: 100 });
  }, []);

  if (resume === null) return null;

  // ... motion.div tree
}

Nothing unusual. Hooks before the conditional. Conditional before the JSX. A defensive return null for the SSR pass where resume hasn’t been computed yet. window.performance.now() is needed to compute it, and window doesn’t exist on the server.

The kind of code you write without thinking. The kind of code that does this to you anyway.

False leads

The first hour goes to the things that should fix it and don’t.

rm -rf node_modules/.vite .astro && npm run dev

Cold start. Same paragraph.

// astro.config.mjs
ssr: { noExternal: ['motion', 'framer-motion'] },

Force Vite to bundle motion through its own module graph so there’s no chance of a second React instance. Same paragraph.

resolve: { dedupe: ['react', 'react-dom'] },

Belt and suspenders. Same paragraph.

The error survives every cache wipe and every dedupe knob. It isn’t a stale-bundle artefact and it isn’t a duplicate-React issue, which is what every Stack Overflow answer for “Invalid hook call” will tell you to chase. It’s something the component is doing, quietly and deterministically, on every request, that the production build pipeline doesn’t care about.

Reproducing it

Strip the component to a stub. Add things back. Watch when the log catches fire.

// silence
export default function BlackHole() {
  const reduce = useReducedMotion();
  const [v, setV] = useState(0);
  useEffect(() => {
    setV(1);
  }, []);
  return (
    <div>
      {v} {String(reduce)}
    </div>
  );
}

// fire
export default function BlackHole() {
  const reduce = useReducedMotion();
  const [resume, setResume] = useState<number | null>(null);
  useEffect(() => {
    setResume(1);
  }, []);
  if (resume === null) return null; // ← trigger
  return <div>{resume}</div>;
}

The only delta is the early return null. Swap null for <span />. Silence.

return null is a normal React pattern. It is, in fact, the normal React pattern for “I have nothing to render yet.” It shouldn’t summon hooks from nowhere.

The probe inside @astrojs/react

Inside node_modules/@astrojs/react/dist/server.js, four lines from the top of the file, there’s a function called check. Before Astro renders a .tsx component it asks every installed framework integration the same question: is this yours? React’s answer is computed like this (source on GitHub):

let isReactComponent = false;
function Tester(...args) {
  try {
    const vnode = Component(...args);
    if (vnode && (vnode['$$typeof'] === reactTypeof || vnode['$$typeof'] === reactTransitionalTypeof)) {
      isReactComponent = true;
    }
  } catch {}
  return React.createElement('div');
}
await renderToStaticMarkup.call(this, Tester, props, children);
return isReactComponent;

It invokes the component. Reads the $$typeof brand off whatever comes back. If it’s React’s symbol, claims the component. If not, hands it off to the next renderer.

On the first probe, resume is null. The component returns null. The vnode && clause short-circuits the brand check. isReactComponent stays false. React’s integration reports back: not mine.

Astro tries another path on the same .tsx file. A fallback that re-invokes the component without setting up the React hooks dispatcher first. useReducedMotion reaches for a dispatcher that isn’t there. The message that comes back is the one React always sends when a hook has nowhere to go: Invalid hook call.

The error message wasn’t wrong. It was pointing at a call site nobody wrote. Astro’s own probe, reaching into your component, in a context where hooks can’t dispatch.

The production build skips this dance. The static pipeline already knows what each island is and doesn’t need to probe. npm run build stays silent. The bug only ever lived in dev, in the seam between two renderers, on the day your initial state happened to be null.

The fix is one vnode of code

-  if (resume === null) return null;
+  if (resume === null) return <span hidden aria-hidden="true" />;

Any element with React’s $$typeof brand will do. <></>, <span />, an empty <div />. The probe needs a vnode, not a yes/no. Give it one. It claims the component, the real render path takes over, hooks dispatch into the right place, the log goes quiet.

If accessibility matters for the placeholder, and for top-of-tree islands it usually does, use <span hidden aria-hidden="true" />. Zero visual cost, no screen-reader noise, one tick of presence in the DOM until the real content takes over.

What the framework was asking

When a framework’s internals reach into your code to ask what are you?, they assume your answer is something they can read. null isn’t an answer. It’s the absence of one. The absence gets routed through whichever fallback the framework has lying around. Sometimes the fallback is benign. Sometimes it’s a hook dispatcher that isn’t there.

The general shape of the rule: if you’re writing an Astro React island with a client:load or client:idle directive, never let the root component return null. Return a placeholder vnode on the SSR pass. Gate the real content on a state flag that flips after useEffect runs. Build never surfaces this. Dev does. If a teammate reports a phantom “Invalid hook call” with a clean build, check every island for return null before you start dedupe-hunting.

The dev server isn’t haunted. It’s just being honest about what nobody asked.