Search code examples
javascriptreactjstypescriptreact-hooksjsx

Can we use React Hooks inside JSX component's callback like this?


Does this count as breaking the rule of hooks? It doesn't show the error/warning 'Error: Invalid hook call' and the code works normally. useEffect also mounts only once

1.✅ Call them at the top level in the body of a function component.
2.✅ Call them at the top level in the body of a custom Hook.
3.🔴 Do not call Hooks inside conditions or loops.
4.🔴 Do not call Hooks after a conditional return statement.
5.🔴 Do not call Hooks in event handlers.
6.🔴 Do not call Hooks in class components.
7.🔴 Do not call Hooks inside functions passed to useMemo, useReducer, or useEffect. https://react.dev/warnings/invalid-hook-call-warning

  1. we technically call the hook in the top level of the HookContainer component
  2. not a custom hook. if we see useHooks as custom hook, it is still in top level
  3. no condition/loop in HookContainer. if HookContainer is in .map or && conditions, the hooks is still init when HookContainer is mount. I think it is still similar to regular component with hook inside since the useHook in HookContainer is init inside the component
  4. it is not called after a conditional return statement
  5. From the example, it's more like a onClick / on* props.
  6. -
  7. -

If it does break the rule of hooks, what's the drawback of using it this way? It would be great if there's a counter example of why this case it an anti-pattern. Thank you very much!

Here's the playground: https://codesandbox.io/p/sandbox/goofy-mcclintock-pchv3f

import { useState, useEffect } from "react";

const App = () => {
  const [name, setName] = useState("");
  console.log("rerender");
  return (
    <div>
      <h1>Hello World</h1>
      <input
        type="text"
        placeholder="name"
        value={name}
        onChange={(e) => setName(e.currentTarget.value)}
      />
      <HookContainer
        useHooks={() => {
          const [counter, setCounter] = useState(0);

          useEffect(() => {
            console.log("HookContainer mount");
          }, []);

          return {
            counter,
            setCounter,
          };
        }}
      >
        {({ counter, setCounter }) => (
          <div>
            <p>counter: {counter}</p>
            <button
              onClick={() => {
                setCounter((prev) => prev + 1);
              }}
            >
              increment
            </button>
          </div>
        )}
      </HookContainer>
    </div>
  );
};

type HookContainerProps<T> = {
  children: (props: T) => JSX.Element;
  useHooks: () => T;
};

const HookContainer = <T extends object>(props: HookContainerProps<T>) => {
  const hookData = props.useHooks();

  const Element = props.children;

  return <Element {...hookData} />;
};

export default App;

------ New Section ---------

Seems like this way also works. The state is also still in order (according to Robin Zigmon comment)

import React, { useState, useEffect } from 'react';


const arr = Array.from({ length: 3 })

export function App(props) {
  const [name, setName] = useState('')
  return (
    <div className='App'>
      <input type="text" value={name} onChange={e => setName(e.currentTarget.value)} />
      {name && arr.map((_, index) => <ComponentWrapper key={index}>
        {() => {
          const [counter, setCounter] = useState(0)
          useEffect(() => { console.log('init') }, [])
          return <div>
            <p>{counter}</p>
            <button onClick={() => { setCounter(prev => prev + 1) }}>increment</button></div>
        }}
      </ComponentWrapper>)}

    </div>
  );
}


const ComponentWrapper = (props) => {
  return props.children()
}

Solution

  • IIUC, this is simply an extra abstraction layer around a custom Hook, that provides props for a Function-as-a-Child, and works without breaking any rule:

    • the callback you pass to useHooks is a custom Hook function
    • that custom Hook is called at the top level inside <HookContainer> Component (as you already realized in your point 2)

    While this abstraction may look strange, I do not see technical issues with it, except for the Function-as-a-Child (FaaC) render prop which is instantiated as a Component (<Element/>), whereas it should just be called as a simple function ({Element(hookData)}): because the callback you pass as FaaC is inlined, it is a new function at each render, hence React destroys and re-creates its DOM nodes everytime, instead of re-using them (to avoid browser re-layout and re-paint, which is the whole point of React Virtual DOM). See https://stackoverflow.com/a/78743482/5108796 and https://kentcdodds.com/blog/dont-call-a-react-function-component (for the reverse reason: keep the inlined FaaC as a "JSX sub-template", in the same context as the <HookContainer> Component, instead of rendering it as a new Component at each render). Or you could memoize it.