Constructors in Functional Components With Hooks

Adam Nathaniel Davis - Apr 5 '20 - - Dev Community

[NOTE: I've since written an update to this article with an improved solution. It can be read here: https://dev.to/bytebodger/streamlining-constructors-in-functional-react-components-8pe]

When you're building functional components in React, there's a little feature from class-based components that simply has no out-of-the-box equivalent in functions. This feature is called a constructor.

In class-based components, we often see code that uses a constructor to initialize state, like this:

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0
    };
  }

  render = () => {
    return (
      <button
        onClick={() =>
          this.setState(prevState => {
            return { counter: prevState.counter + 1 };
          })
        }
      >
        Increment: {this.state.counter}
      </button>
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

Honestly, I've come to view code like this as silly and unnecessarily verbose. Because even in the realm of class-based components, the exact same thing can be done like this:

class App extends Component {
  state = { counter: 0 };

  render = () => {
    return (
      <button
        onClick={() =>
          this.setState(prevState => {
            return { counter: prevState.counter + 1 };
          })
        }
      >
        Increment: {this.state.counter}
      </button>
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

As you see, there's no need for a constructor simply to initialize your state variables, unless you have to initialize the state variables based upon the props. If this isn't necessary, you can declare initial state directly inside the class.

Constructors... for Functions?

If we transition into the functional/Hooks side of things, it would seem that the Hooks team had the same idea. Because when you look at the FAQ for Hooks, it has a section dedicated to answering, "How do lifecycle methods correspond to Hooks?" The first bullet point in this section says:

constructor: Function components don’t need (emphasis: mine) a constructor. You can initialize the state in the useState call. If computing the initial state is expensive, you can pass a function to useState.

Wow...

I don't know if this "answer" is ignorant. Or arrogant. Or both. But it doesn't surprise me. It's similar to some of the other documentation I've seen around Hooks that makes all sorts of misguided assumptions for you.

This "answer" is ignorant because it assumes that the only reason for a constructor is to initialize state.

This "answer" is arrogant because, based on its faulty assumptions, it boldly states that you don't need a constructor. It's like going to the dentist for a toothache - but the dentist doesn't fix the problem. He just pats you on the head and says, "There, there. You don't really need that tooth. Now run along..."

The massive oversimplification in their dismissive FAQ overlooks the basic fact that there are other, perfectly-valid use-cases for a constructor (or, constructor-like functionality) that have nothing to do with initializing state variables. Specifically, when I think of a constructor, I think of these characteristics.

  1. Code that runs before anything else in the life-cycle of this component.

  2. Code that runs once, and only once, for the entire life-cycle of this component.

To be clear, is a constructor usually needed in most components? No. Certainly not. In fact, I'd say that the need for constructor-type logic is the exception, not the rule. Nevertheless, there are certain times when I absolutely need logic to run before anything else in the life-cycle of this component, and I absolutely need to ensure that it will run once, and only once, for the entire life-cycle of this component.

So despite the Hooks team's bold assertions, the fact is that there are times when I do need a constructor (or some equivalent).

The Challenge of Functional/Hooks Life-Cycles

The biggest "problem" with life-cycles in functions/Hooks is that... there are none. A function doesn't have a life-cycle. It just... runs. Whenever you call it. So from that perspective, it's understandable that there's no easy, out-of-the-box equivalent for a constructor in a functional component.

But despite the Holy Praise that JS fanboys heap upon the idea of functional programming, the simple fact is that a functional component doesn't really "run" like a true function. Sure, you may have that comforting function keyword at the top of your code (or, even better, the arrow syntax). But once you've created a functional component in React, you've handed over control of exactly how and when it gets called.

That's why I often find it incredibly useful to know that I can create some bit of logic that will run once, and only once, before any other processing takes place in the component. But when we're talking about React functional components, how exactly do we do that? Or, more to the point, where do we put that logic so it doesn't get called repeatedly on each render?

Tracing the "Life-Cycle" of Functions/Hooks

(NOTE: If you want to see a live example of all the subsequent code, you can check it out here: https://stackblitz.com/edit/constructor-hook)

This will best be illustrated with some examples. So let's first look at a dead-simple example of logic that runs in the body of a function:

const App = () => {
  const [counter, setCounter] = useState(0);

  console.log("Occurs EVERY time the component is invoked.");
  return (
    <>
      <div>Counter: {counter}</div>
      <div style={{ marginTop: 20 }}>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This is the simplest illustration of a function's "life-cycle". In a class-based component, we had the comfort (IMHO) of a render() function. And if a particular bit of logic should not run on every re-render, the process was pretty straight-forward: Just don't put that logic in the render() function.

But functional components offer no out-of-the-box equivalent. There is no render() function. There is only a return. The return (and all the rest of the code in the body of the function) gets called every single time this function is called.

I will freely raise my hand and admit that this threw me for a loop when I first started writing functional components. I would put some bit of logic above the return, and then I'd be surprised/annoyed when I realized it was running every single time the function was called.

In hindsight, there's nothing surprising about this at all. The return is not analogous to a render() function. To put it in different terms, the entire function is the equivalent of the render() function.

So let's look at some of the other Hooks that are available to us out-of-the-box. First, I spent time playing with useEffect(). This leads to the following example:

const App = () => {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    console.log(
      "Occurs ONCE, AFTER the initial render."
    );
  }, []);

  console.log("Occurs EVERY time the component is invoked.");
  return (
    <>
      <div>Counter: {counter}</div>
      <div style={{ marginTop: 20 }}>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This gets us a little closer to our goal. Specifically, it satisfies my second condition for a constructor. It is run once, and only once, for the entire life-cycle of this component.

The problem is that it still runs after the component is rendered. This is completely consistent with the Hooks documentation, because there it states that:

By default, effects run after (emphasis: mine) every completed render.

I also played around with useLayoutEffect(), which leads to this example:

const App = () => {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    console.log(
      "Occurs ONCE, AFTER the initial render."
    );
  }, []);

  useLayoutEffect(() => {
    console.log(
      "Occurs ONCE, but it still occurs AFTER the initial render."
    );
  }, []);

  console.log("Occurs EVERY time the component is invoked.");
  return (
    <>
      <div>Counter: {counter}</div>
      <div style={{ marginTop: 20 }}>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

useLayoutEffect() gets us no closer to having a true "constructor". useLayoutEffect() fires before useEffect(), but it still fires after the render cycle. To be fair, this is still completely consistent with the Hooks documentation, because useLayoutEffect() is still... an effect. And effects always fire after rendering.

So if we want something that truly approximates the functionality of a constructor, we'll need to manually control the firing of that function. Thankfully, this is totally in our control, if we're willing to manually crank out the code that's needed to support it. That would look like this:

const App = () => {
  const [counter, setCounter] = useState(0);
  const [constructorHasRun, setConstructorHasRun] = useState(false);

  useEffect(() => {
    console.log(
      "Occurs ONCE, AFTER the initial render."
    );
  }, []);

  useLayoutEffect(() => {
    console.log(
      "Occurs ONCE, but it still occurs AFTER the initial render."
    );
  }, []);

  const constructor = () => {
    if (constructorHasRun) return;
    console.log("Inline constructor()");
    setConstructorHasRun(true);
  };

  constructor();
  console.log("Occurs EVERY time the component is invoked.");
  return (
    <>
      <div>Counter: {counter}</div>
      <div style={{ marginTop: 20 }}>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This gets us a lot closer to the stated goals. The manual constructor() function runs once, and only once, for the "life-cycle" of this function. It accomplishes this goal by leveraging a manual state variable - constructorHasRun - and refusing to re-run the constructor() functionality if that variable has been flipped to true.

This... "works". But it feels very... manual. If you require constructor-like features in your functional components, then under this approach, you'd have to manually add the tracking variable to the state of every component in which it's used. Then you'd need to ensure that your constructor() function is properly set up to only run its logic based on the value in that state variable.

Again, this "works". But it doesn't feel particularly satisfying. Hooks are supposed to make our life easier. If I have to manually code this functionality in every component where I need constructor-like features, then it makes me wonder why I'm using functions/Hooks in the first place.

Custom Hooks to the Rescue

This is where we can leverage a custom Hook to standardize this process. By exporting this into a custom Hook, we can get much closer to having a "true" constructor-like feature. That code looks like this:

const useConstructor(callBack = () => {}) => {
  const [hasBeenCalled, setHasBeenCalled] = useState(false);
  if (hasBeenCalled) return;
  callBack();
  setHasBeenCalled(true);
}

const App = () => {
  useConstructor(() => {
    console.log(
      "Occurs ONCE, BEFORE the initial render."
    );
  });
  const [counter, setCounter] = useState(0);
  const [constructorHasRun, setConstructorHasRun] = useState(false);

  useEffect(() => {
    console.log(
      "Occurs ONCE, but it occurs AFTER the initial render."
    );
  }, []);

  useLayoutEffect(() => {
    console.log(
      "Occurs ONCE, but it still occurs AFTER the initial render."
    );
  }, []);

  const constructor = () => {
    if (constructorHasRun) return;
    console.log("Inline constructor()");
    setConstructorHasRun(true);
  };

  constructor();
  console.log("Occurs EVERY time the component is invoked.");
  return (
    <>
      <div>Counter: {counter}</div>
      <div style={{ marginTop: 20 }}>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

If you want to see it without the failed attempts to use useEffect() and useLayoutEffect(), and without the manual implementation of constructor(), it looks like this:

const useConstructor(callBack = () => {}) => {
  const [hasBeenCalled, setHasBeenCalled] = useState(false);
  if (hasBeenCalled) return;
  callBack();
  setHasBeenCalled(true);
}

const App = () => {
  useConstructor(() => {
    console.log(
      "Occurs ONCE, BEFORE the initial render."
    );
  });
  const [counter, setCounter] = useState(0);

  return (
    <>
      <div>Counter: {counter}</div>
      <div style={{ marginTop: 20 }}>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

By leveraging a custom Hook, we can now import the "constructor-like" functionality into any functional component where it's needed. This gets us, umm... 99% of the way there.

Why do I say that it's only 99% effective?? It satisfies both of my conditions for a "constructor". But... it only accomplishes this goal, in the example shown above, because I invoked it at the very top of the function.

There's still nothing stopping me from putting 100 lines of logic above the useConstructor() call. If I did that, it would fail my original requirement that the logic is run before anything else in the life-cycle of this component. Still... it's a fairly decent approximation of a "constructor" - even if that functionality is dependent upon where I place the call in the function body.

For this reason, it might be more intuitive to rename useConstructor() to useSingleton(). Because that's what it does. It ensures that a given block of code is run once, and only once. If you then place that logic at the very top of your function declaration, it is, effectively, a "constructor", for all intents and purposes.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player