2

Out of curiosity I wanted to call a React function component directly; like a regular function. However, React complains:

Invalid hook call. Hooks can only be called inside of the body of a function component. 

How does React know that? Here's the example code:

const T = () => {
  useEffect(() => console.log("effect"));
  return (
    <>
      <div> hello </div>
    </>
  );
};

I extracted the useEffect like so:

const T = (func = useEffect) => () => {
  func(() => console.log("effect"));
  return (
    <>
      <div> hello </div>
    </>
  );
};

And then re-triggered it via: T()() which doesn't work. But T(f=>f())() does work.

Is that true of every hook or is this particular to React supplied hooks only? Would creating an arbitrary custom hook (that doesn't depend on an existing React hook) also cause the same error? What is happening under the hood for React to determine this and why must it be an error to try and do this? Per my understanding these are simply JS functions that return a React Element (React.createElement) which themselves are just JS objects, no? What am I missing? Is there a way to force an execution other than extract every hook dependency and pass in a stubbed implementation?

PhD
  • 11,202
  • 14
  • 64
  • 112
  • 1
    I think this is a very good question, which I don't think I can answer personally. You inspired me to have a quick look at the React source code, where I did find [this error message](https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js), and found it happens when the "current dispatcher" (which is tracked via something that looks a bit like a React ref) is `null`. So that must be what keeps track of whether you're inside a function component or not - but my knowledge of the source code is not good enough to understand how that works in any detail. – Robin Zigmond Feb 28 '23 at 21:24

1 Answers1

5

You can see where it's set up in the React source code. Outside of a mounted component, the standard hooks (useEffect, useState etc) are initialised to throwInvalidHookError, which does the obvious thing.

Within a mounted function component, useEffect is set to the mountEffect function which does the expected effect-running stuff.

Custom hooks aren't treated specially (although the standard linting rules will report any abuses), but a custom hook that doesn't call any of the standard hooks – either directly or indirectly – doesn't need to be a hook at all. If it does call one of the standard hooks then, obviously, the Invalid hook call exception will be thrown.

why must it be an error to try and do this

It needs to be an error because the standard hooks have special treatment inside the React machinery. The "rules of hooks" result in a consistent ordering of hooks within each component; they are enforced so that React can maintain internal state associated with hook call.

This is a design choice by the authors of React. Some limitations are put in place, it results in optimisations in the implementation.

Per my understanding these are simply JS functions that return a React Element

Yes, components are just JS functions that return a representation of a React node tree. React then converts this node tree into HTML nodes with associated event handlers etc.

Is there no way to do this other than extract every hook dependency and pass in a stubbed implementation?

It's not entirely clear what you're trying to do here, but the short answer seems to be "correct, there is no way without restructuring the code". You can either use regular functions to call regular functions, or use hooks to call hooks (and regular functions). There's no magic to it – you can make your own function into a hook just by starting its name with use, then it can call other hooks, as long as it follows the rules of hooks.

EDIT

I simply would like to invoke the component like so T().

It's still not clear what problem you're hoping to solve by doing that. The return value from a component is a data structure reflecting the component tree. You can see for yourself by writing a hookless component:

const HelloWorld = () =>
  <div>Hello <span id="world">World</span></div>

console.log(HelloWorld());

Sandbox

{
  "type": "div",
  "key": null,
  "ref": null,
  "props": {
    "children": [
      "Hello ",
      {
        "type": "span",
        "key": null,
        "ref": null,
        "props": {
          "id": "world",
          "children": "World"
        },
        "_owner": null,
        "_store": {}
      }
    ]
  },
  "_owner": null,
  "_store": {}
} 

So, it's a familiar tree structure with some extra slots for React housekeeping data.

We can explore a little further by calling HelloWorld() from a mounted component:

const App = () => {
  console.log(HelloWorld());
  return <div></div>
}
...
root.render(<App/>);

Sandbox

This renders an empty <div></div>, but the return value from HelloWorld() contains some new innards:

{
  "type": "div",
  "key": null,
  "ref": null,
  "props": { "children": ... },
  "_owner": FiberNode,
  "_store": {}
}

The FiberNode value hints at what React is doing behind the scenes – Fiber is the algorithm that does what React does, FiberNode is an internal data structure it uses to do that.

What happens if we add a hook call to HelloWorld()?

const HelloWorld = () => {
  useEffect(() => console.info("Howdy!"));
  return <div>Hello <span id="world">World</span></div>
}

Sandbox

Of course if we call this directly then we get the familiar Invalid hook call error, but if we call it from a mounted component ... we see Howdy! on the console output. Interesting ....

This works because, as you mentioned, these are all just plain old Javascript functions. Calling useEffect() from HelloWorld() from <App/> does the same work as calling useEffect() directly from <App/>. This is not, however, the same as calling useEffect() from <HelloWorld/>.

Let's try something else. What happens if we create a <HelloWorld/> element?

const App = () =>
  {
    console.log("<HelloWorld/>", <HelloWorld/>);
    return <div/>
  }
...
render(<App/>);

Sandbox

We no longer get a Howdy!, and the return value is different:

<HelloWorld/>
{
  "type": f HelloWorld() {}
  "key": null,
  "ref": null,
  "props": {},
  "_owner": FiberNode,
  "_store": {}
}

Very interesting. When the function component is referenced via JSX, the function does not get called. <HelloWorld/> is just syntactic sugar for calling createElement(HelloWorld), and the function only gets called when that element is rendered.

Comparing the return values, we also see that the children are no longer visible from the outside, and the tree structure is just a single HelloWorld node rather than a nested div / span / text structure.

Let's try one more thing. What if we only call HelloWorld some of the time?

const App = () => {
  const [count, setCount] = useState(1);
  return (
    <div onClick={() => setCount((c) => c + 1)}>
      Click #{count}
      <hr />
      {count <= 1 &&
        /* render HelloWorld component */
        <HelloWorld /> }
        
      { count <= 2 &&
        /* call HelloWorld function */
        console.log("HelloWorld()", HelloWorld()) }
    </div>
  );
};

Sandbox

Clicking on the <div> will update the count, and we'll call different things each time.

On first load, we see Hello World! rendered in the browser, and the console shows us:

Howdy!
Howdy!

HelloWorld() is invoked twice (which gives us the two greetings) – once from it being rendered, and once from the direct call.

Clicking to get a second render, we see:

Howdy!

We are no longer rendering <HelloWorld/>, so we only see the greeting from our direct call to HelloWorld().

Clicking to get a third render ...

Rendered fewer hooks than expected.

Aha! We broke the rules of hooks and React complained about it. The first two times we rendered <App/>, the useEffect() hook was called, but the third time it wasn't. Hooks can't be called conditionally.

But ... it didn't complain when we rendered <HelloWorld/> conditionally. That's the same function calling the same hook! This is outrageous, it's unfair, etc Each component instance gets its own ledger for keeping track of hooks. When we call HelloWorld() directly, the useEffect() is tracked in the App instance. When we render <HelloWorld/>, the useEffect() is tracked in the HelloWorld instance. If we render <HelloWorld/> multiple times, each instance tracks its own useEffect() call.


Apologies for the lengthy but not-particularly-comprehensive peek behind the curtains of React function components and effects. I hope it went some way to address your open-ended "why must it be an error to try and do this?" / "what am I missing?" questions.

The really short answer is "when you put <Foo/> in your component tree, you're not calling it directly".

In terms of your original premise: "Out of curiosity I wanted to call a React function component directly", you found out what happens. React complains! If you have a packhorse and you remove oxygen and gravity, it doesn't work too well either.

motto
  • 2,888
  • 2
  • 2
  • 14
  • Thanks for the pointer to the source code. It does point to "where" it's codified and implies that something with the dispatcher look causes a `null` which leads to this error. However, this doesn't really throw light on the various questions I put forth regarding the how, why and how may it be side-stepped if needed. It would be great if you could augment your answer to help with that. – PhD Feb 28 '23 at 21:38
  • 1
    @PhD thusly augmented – motto Feb 28 '23 at 22:00
  • I simply would like to invoke the component like so `T()`. I understand it could be done by mocking `ReactCurrentDispater.current = { useEffect: f => f() }` but I can't get that to work. – PhD Feb 28 '23 at 23:21
  • @PhD Hope the new edit addresses all your questions and helps you understand why that's likely to lead you to heartache. At some point you're just re-writing React :-) – motto Mar 01 '23 at 10:29