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.
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.
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
FancyTablewould have no way of knowing anything changed. That's problematic if
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
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 combines the lifecycle methods
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!
useMemo API is obvious. Whatever the passed function returns will be the result assigned to
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.
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.
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
useRef, and look into some advanced component functionality that we can leverage custom hooks to create. Thanks for reading!
Quick Reference Links: