A Deep Dive into React Hooks and Complex Functional Components


07 November 2019 by Daniel Timko

About 4 months ago, I was given the opportunity to architect a greenfield react project with staggering amounts of complexity. I had only heard of hooks in passing before this project, but knew they were the new hotness in the world of front-end react development. Hooks could purportedly remove the need for redux as a state management tool, cut boilerplate code in half, and improve performance significantly. I thought to myself, "These claims are obviously exaggerated. Surely a production grade application with a feature rich front-end needs class components, right?"

I was wrong.

Shockingly wrong.

Almost embarrassingly wrong, if I am being honest. And I think one of the biggest reasons for my false assumptions were the utter lack of any in depth guides on seriously complex hook-based components. My goal with this article series is to remedy that by walking you through what hooks are, how they work, and what you can do with them in explicit detail. Throughout the series, I'll be referring to the Material UI table library I built for our team to use as a 'working example' of sorts. We will start with the basics and work our way up to a final, production-ready component with dozens of features. As a sneak preview, here is the final result built entirely with hooks and functional components.

Part 1 - What are hooks and how do they work?

Traditional functional components are stateless. React passes a props object to the function, the code executes line by line, and the component returns something renderable. Easy, right? This type of component is deterministic; the same input props will always produce the same output.

But then hooks came along, allowing functional components to express stateful behavior while maintaining all of the performance and readability benefits, they have over ES6 class components. When I first began utilizing hooks in my code, I thought they were black magic. But there's nothing magical about them at all. Hooks aren't built with fancy new javascript features, they're simply a new paradigm for attaching class-like behavior to functions using existing features of the language. It's helpful to think of hooks as reusable extensions to the main body of a functional component with some extra features and their own scope.

When a function returns, its scope and variables (generally) get garbage collected. In order for state to persist between calls of our functional components, we need an efficient way to re-fetch a component's state every time the main function body executes. This is where hooks come in. Each hook has its own module file, giving every calling instance access to the same lexical scope. ES6 modules (or IIFEs if using ES5 or earlier) are treated as 'scope singletons' by the JavaScript engine. If you've got no idea what I'm talking about, I'd recommend reading this article about scope & closure.

Let's look at a contrived example:

In practice, this is a very bad hook for reasons I'll get to shortly. But it does illustrate the fundamental way all hooks work. Notice how the columns object is only stored in state on the first call, and all subsequent calls simply return that state. Also, notice how there is nothing fancy going on here. We could easily include the hook's code in the table component's body, and it would work exactly the same:

So why use hooks at all? To quote the react docs, "Hooks allow you to reuse stateful logic." Say we have a different type of table called "FancyTable" with additional features. Instead of re-writing all of our stateful logic, we can import the same hook and use it the same way. We can also use the 'state singleton' nature of modules. Since there is only ever one instance of the hook's module scope, we can also access the previous table's columns in our new, unrelated component!

As a quick aside, that second feature isn't very useful in this hypothetical. But imagine a "useSharedVariable" hook that allows any component to have access to the same shared state. Stuff like logged in user data, api calls/responses, and form inputs. Hooks enable developers to easily access all of it from anywhere in their application with a single line of code. Compared to the standard Redux way of doing things, this approach is far superior in my opinion. If you'd like to learn more about replacing redux with React Hooks, this is a great article on the topic. It talks about how to create a shared variable hook factory enabling you to quickly create shared state hooks and maintain separation of concerns. The article does assume a working knowledge of hooks, however, so I'd recommend finishing this introduction to hooks first

This basic implementation gets to the heart of how hooks work. But I can immediately notice numerous flaws:

  • Any component that imports and uses this hook has access to all of the state of other components that use it. If that isn't an intentional feature, it could be a severe vulnerability.
  • Defining a unique ID for every component in the app sounds exhausting, infeasible, and error-prone when developers get sloppy.
  • The state won't ever update, even if the columns prop changes.
  • Even if we could somehow update the column state from the first Table, our FancyTable would have no way of knowing anything changed. That's problematic if FancyTable relies on Table's data for rendering.

Obviously, there are a few vital properties of hooks missing here. The solution, counterintuitively, is to do away with IDs entirely. If every hook a component uses is called every time that component renders, then the hook call order in any given tree of components remains constant. React relies on that deterministic nature to properly store and return various component's state in the absence of unique identifiers. The tradeoff is that hooks can never be called conditionally - a small price to pay for such a powerful tool.

The most basic and frequently used hook in the React API is useState. It stores an initial value on the first render and returns the current value along with a setter on the first and subsequent calls. Since useState returns these values in an array, the convention is to use destructuring assignment to access the values. Let's look at how we could rewrite our component with the useState hook.

That is much, much cleaner and immediately understandable. But there's a gotcha: any time the stored state value changes, React identifies that the currently displayed version of the component is stale and re-renders it. When this implementation of the table renders, it calls setTableColumns to update local state based on props. That causes a re-render, which causes another call to setTableColumns, which causes another render, etc. So how do we fix this? With the useEffect hook.

useEffect combines the lifecycle methods componentDidMount, componentDidUpdate, and componentWillUnmount for use in a functional component. It takes a function as its first parameter that defaults to running once after every render. It can also take a second parameter - a dependency array. If the second parameter is included, useEffect will call the function once after the first render and only call it again if one of the dependencies in the array changes. This hook is incredibly versatile and can be used to trigger a variety of what React calls side effects such as calling an API or, as in our case, updating hook state which causes the component to re-render.

But that only covers 2 of the 3 lifecycle methods useEffect is meant to replace. To emulate the behavior of componentWillUnmount, you can return a cleanup helper from the effect body that react will call just before the component unmounts:

"I thought hooks reduced boilerplate" I hear you say. Its true, this is a tad verbose for simply keeping a local state variable updated when props change. Lucky for us, this is an incredibly common pattern when developing with hooks so the react devs gave us a helper: useMemo. It can be used in other ways (which we'll get to), but for this example we are using it to keep a state variable updated based on a dependency array, no setter required!

Hopefully the useMemo API is obvious. Whatever the passed function returns will be the result assigned to tableColumns. Then, useMemo stores that result and returns it on every subsequent render unless something in its dependency array changes, at which point it re-runs the passed calculation function to fetch a new value. Actually, this calculation function pattern is fairly common with hooks. The whole point of the feature is to set up state in a non-blocking way, and have it trickle into the app as it becomes available. For example, useState also accepts a calculation function as its initial state value: useState(() => (props.columns)).

I'll briefly note here that useMemo doesn't actually call the same function. It calls the same code, but every time the Table component is rendered, that function gets redefined. useEffect and all of the other hooks that take functions as arguments work the same way. It seems like passing in a predefined function from outside the scope of the component would be a good optimization here. And it is! Unless you want access to any local variables or props. Due to the way closures work in JavaScript, when a function gets defined and a scope is created, it retains access to that scope even after the scope is exited. If we weren't redefining useMemo's setter function every render, it would end up accessing a stale version of columns. This is why the dependency array is so vital to the functionality of hooks. It lets react know when it should call the calculation function again even though the function's reference in memory is different after every component update.

We have covered many amazing hook features in this article so far. The only real thing that is missing is some way to inject logic into state updates. useMemo's setter function is called internally by react and doesn't accept any arguments. The setter returned from useState can technically accept an update function instead of a new value, but it lacks much of the power you'd expect from a library like redux. Lucky for us, the react devs thought of this.

Introducing useReducer. That's right, hooks give us full redux functionality without the headache. It accepts a reducer and an initial state as arguments and returns the current state as well as a dispatch function. Let's try using it for table columns!

We covered a lot today, but hopefully you're starting to see the power of hooks. With the primer on hooks out of the way, next time we will start looking at some code from my table component and walking through the architectural decisions I had to make along the way. We'll cover new core hooks like useCallback, useContext, and useRef, and look into some advanced component functionality that we can leverage custom hooks to create. Thanks for reading!

 

Quick Reference Links:

Material UI Library

React App

You Don't Know JS 

Introducing Hooks

Global state management with React Hooks

Destructuring Assignment

What does Side effects mean in React?


Daniel Timko
Daniel Timko

Daniel recently graduated from Rochester Institute of Technology with a BS in Game Design and Development. He has been programming computers since middle school and really enjoys the problem-solving process inherent to coding. In recent years he has worked extensively with Node.js, React, MongoDB, Socket.IO, and many other frameworks.