React Hooks and Project Evolution

Andrew Crites
Mar 19 · 14 min read

React hooks are a relatively new mechanism in the React framework that allow you to manage state and side effects with functions called hooks. This allows you to write some components as functions rather than as classes and still maintain state. Hooks also allow for reusability of logic across multiple components rather than having logic isolated in particular components.

For those who are interested in but still brand new to hooks, you may be curious as to how individual hooks work and when and how you would use particular hooks. To this end, I’m going to walk through the evolution of hook usage on my own project.

The project used in this article is a CSS color tool. We’ll start with an app that’s two inputs: a Hex color code input, and a set of four inputs representing RGBA. The idea is that if you update the hex color, the corresponding RGBA change should be reflected as well and vice versa. This allows you to easily change representations of CSS color values. For example, if you type #abcdef into the hex input, the RGBA inputs will update to 171, 205, 239, and 1 (hex has no alpha value, so its counterpart in RGBA is always 1).

First, some links for the interested:

The actual color tool I created is more complicated than the example I’m going to walk through here. The examples I walk through will be in TypeScript. Feel free to refer to the repo to see how I set up the project to work with TypeScript. I’ll omit explanations of that in this article.

Function Components and useState

The hex and RGBA components are relatively simple since they’re just inputs that you can tap into to get and set color values.

Of course, these are not particularly useful since you have no way to set values in the components from outside of them nor can you use changes in the components to update external values. In order to do that, we’re going to have to have some application state that keeps track of the current color values. We can keep this state and manage it at the top level.

I won’t go into a lot of detail about how the useState hooks works. You can read React’s documentation and many blog posts including my own for a more thorough explanation. Suffice to say, useState returns a new value on each render. You can trigger a render by calling the setX functions that useState returns. Our example app here does not call these, so nothing is going to happen. Let’s change that now:

First, we pass the current hex value hex and the setHex function down to our HexInput. When we make changes to the <input>, (for example, by typing in it) our setHex function gets called with the new value via the onChange event. This updates hex in our app component which passes it to HexInput and updates the input’s value.

Similarly with RgbaInput, changes to one of the inputs calls the setRgba with the corresponding piece updated. We need to update the rgba as a unit, so we make a copy of the rgba array via [...rgba] and update the appropriate index with the new value from onChange. Then, we have to call setRgba to change it in the App. This change propagates to the RgbaInput as well in the same way that hex is propagated.

This is a functional start, but we can probably clean up our RgbaInput pretty trivially by using an array. We can also acquire all of the values of the other RgbaInputs to reconstruct what the rgba array should be from all input values may result in a more consistent experience instead of only updating values in rgba itself.

React introduces another hook that allows you to create DOM node refs: useRef. Note that useRef can be used to keep track of any mutable value, similar to a class instance property. It can also be used like classic refs for DOM nodes in React.

Here we create an array of refs that we can iterate over to create our corresponding inputs. We can also iterate over the refs to get the input values (this is done in onChange). We can use this to reconstruct what the rgba array should be and call setRgba. We’ve removed any dependency on specific indices and arguably simplified our code quite a bit.

Note that the { current: ... is used because refs have a current value that is created when the corresponding element is rendered. This current contains the current DOM node. This is similar to how React.createRef works already. The value is just inputElement.value. Note that any value created with useRef will have a .current, not just DOM node refs.

You may notice that our app allows us to update the hex input and rgba input, but updating one does not update the other as per our original specifications. One naive way that we could do this would be to call all of our setX functions whenever we need to update.

It will be annoying to have to continue to pass our setX functions to every input component … especially as we add more and more of them. Rather than pass individual pieces of the application state around, we can use a React context to pass the entire state.

It would also be reasonable to have a single useState in our app component such as const [{ hex, rgba }, setColors] = useState({ hex: '', rgba: [0, 0, 0, 0]);. Then, we would have to pass setColors and call it like setColors({ hex, rgba }). That would also simplify the amount of state we have to pass around, but we’ll stick with multiple useStates for our examples for now and go in a different direction.

useContext for State Slicing

Be mindful when using React Contexts. When a component depends on a context, it’s tightly coupled to the state associated with that context. Of course, that’s necessary in some cases since your app will have components that are tied to specific functionality. We’ll use context here to manage our different color representations and their update functions as they change.

First, we can create our context as we would in a non-hook React app.

We haven’t added any new hooks or anything here. Instead, we create a context to maintain all of our values in a state object simultaneously. The ColorContext.Provider will pass these state values to the input components.

Originally, we would have to use ColorContext.Consumer in the input components to access our context. However, there is a hook, useContext, that we can use to get the current context values instead. Updates to the context values (state in our app component) will trigger a render that will update their values inside the input components as well.

Of course, we don’t want to call setRgba with a hex value. We can theoretically parse the hex color and get an rgba value out of it that we can set and vice versa.

In our examples, we’re going to use an actual library parse-color to do this, but the examples won’t go into detail about how that library works. Just imagine that it can magically give you the color representations you want from any value you give it.

You can of course have multiple contexts throughout the app. There’s no difference between how context is used in projects consuming hooks via useContext or Consumer components.

useEffect for Side Effect Management

Let’s say that we want to introduce another input, <input type=color> that will be a color picker that allows users to select a color interactively. We can create another input component for this, and the color input type uses hex values like #abcdef, so we can use our hex value to set our color input as well.

One thing to keep in mind is that this <input type=color> is not supported by all browsers. We might want some special behavior for unsupported browsers. In our case, we simply won’t show the color input at all.

There’s a kludgey check for color input support: set its value to anything other than a valid hex color code such as '!'. Then, read the input value. If you get back your invalid value, the color input type is not supported.

In class-based components, we would probably do this check in componentDidMount, but instead we can use the useEffect hook. This hook allows us to manage side effects during component render cycles.

We can also provide a destructor function that works similarly to componentWillUnmount, but there are nuanced differences that I won’t get into here. We don’t need to do that in this example since we’re not setting up any sort of subscription or listener.

We combine this with useState to keep track of whether the component should be shown or not. We also use useRef to access the DOM element of the color input itself and get its value.

useEffect takes a callback that runs when the component updates including its initial update. This will run every time the component updates which is unnecessary for us. We only need to do the support check once.

useEffect takes a second argument: an array of values to compare against the last render. If these values all match, useEffect is skipped. For example, if we did something like useEffect(cb, [hex]), then cb would only run any time our hex value updated in the component. You can also pass an empty array. This will result in the useEffect callback running only once since the comparator values never changed (there aren’t any, so there’s nothing to change). This is probably the closest analog to componentDidMount.

Reusing setX Logic with useReducer

Passing the setX (setHex , setRgba) functions as part of context is convenient, but one problem is that we can’t reuse the logic we’d need to create an rgba value from a hex value and vice versa. This is a problem since we get a hex value in two spots: HexInput and ColorInput. This would still be a problem even if we were only using a single setColor function for state management too. For now, we only need to get a hex value from rgba in one spot: RgbaInput. However, we may want to extract out this logic so that we can reuse it to update the state consistently when we need to.

Moreover, if we add more inputs or other functionality that can update various color values, we’d have to remember to call setHex, setRgba, and anything else that we might add later on everywhere where we update a color value for consistency. This is redundant and error prone.

Fortunately, a solution exists in useReducer. This hook is actually the foundation for useState and many other built in hooks. For those familiar with Redux, you should already understand the concept of a reducer. For the uninitiated, a reducer is a function that you could pass to [].reduce. More specifically, it’s a function that takes two arguments: a state and an action, and returns a new state based on the given action in an idempotent manner.

This allows you to reuse logic by dispatching actions in your components since all of your state management logic lives in the reducer and effectively flows through your reducer. useReducer is similar to useState in that it manages a component’s state and returns the current state and a mechanism to update state. This mechanism is called dispatch. When you call the dispatch function, you provide an argument that is your action: you tell your reducer how you want to update your state. Let’s take a closer look.

Since our reducer function handles updating all state, we no longer need setX functions, so we can remove them from our context.

Next, we actually have to create our reducer function. Recall: this function takes the current state and the action with instructions to update as arguments.

Note that this is just one example of how you can write a reducer function. While you may be familiar with using switch to filter the action, and it’s often convenient to do so, you can handle this any way you like.

In the example above, we handle two possible actions; UPDATE_HEX and UPDATE_RGBA. They are similar, but the check we do on the provided value (provided via payload) is different for the color type. Both actions update both the hex and rgba values. Note that we also have a default handler for unknown actions that simply returns the current state.

If you don’t understand what the reducer function is doing or how we’ll use it, don’t worry too much. We’re going to go over that now, so you may want to revisit the reducer function again once you get clarity for how it’s actually used by our components. Also keep in mind that this is just a function. There are no side effects of this function, and there’s nothing special happening here at all. It’s just a switch against the string action.type which leads to a check against the hex string or rgba array and returns an object with properties from the parsed color. You could import it anywhere you wanted and call colorReducer({}, { type: 'UPDATE_HEX', payload: '#abcdef' }) and it would return { hex: '#abcdef', rgba: [171, 205, 239, 1] }.

Now that we have a reducer function, we can actually use useReducer in our app component instead of the useStates that we had before. useReducer takes two arguments: the reducer function and an initial state.

This change fairly analogous to what we had before. We provide the same starting state values for hex and rgba. When our app component renders the first time, useReducer will return an array with two values: first, whatever we set for the initial state (the second argument to useReducer), and second, the dispatch function. Instead of passing the setX callback functions as part of our context, we pass dispatch. This will be our input components’ mechanism for passing data through the reducer and updating state.

Our input components have arguably gotten simpler. Any logic for color parsing we might have had before can now be handled by the reducer. If we want to make an update to a color, we only have to pass the dispatch function that we get from context. We pass our action to dispatch. Actions can be whatever we want, but by convention they are objects that have a type property that is a string representing which action we want to perform, and a payload that has relevant data relating to updating the action. Ideally, actions are serializable. This makes them easier to log.

If you’re wondering where our colorReducer function comes in, it gets called whenever we call dispatch. It actually calls the colorReducer function and passes in whatever the current state is as the first argument and our action object that we passed to dispatch as the second argument. colorReducer runs as normal and returns our new state. This in turn triggers a render on the component where we used useReducer (our app component in this case), and the useReducer call will return the updated state values. This also allows us to make modifications based on whatever the current state is.

We’re now free to add additional inputs or other controls that can be used to update our application state by dispatching actions. We have our logic in a single place where it will be consistently called as needed to update our app’s color values in a way that we expect. We also don’t need to worry about updating any of our other components if we decide to add another color type (for example hsla) since we’ve decoupled setting particular state properties from the child components of the app and made it the responsibility of the reducer. dispatch is our mechanism for updating state in our components.

If you’re a TypeScript developer, as I am, you might want to add some type safety for your reducers and actions. There are a ton of different ways to do this, but I highly recommend the typesafe-actions library that you can install from npm.

This allows you to create action creator functions and specify payload parameters for the actions. These action creators can be used with dispatch and can also be used as type guards for your reducer’s switch. typesafe-actions will correctly set the payload type based on the guarded action using getType as in the example above. Note that this does not work properly if we destructure the action argument to the reducer, so I kept it as action.

Testing

Fortunately for us, there’s nothing special needed when it comes to testing React components that are made with hooks. For the most part, these function like any other React component. Note that you might run into some issues if you’re refactoring existing class components with existing tests to use hooks, but out of the box, testing hooks won’t require anything that you wouldn’t be used to when testing other function components.

Testing a reducer is simple since it (should be) is a pure function. If you have known inputs and outputs, you can just call the reducer function with the known input and assert the output matches what you expect.

Of course, this test doesn’t have anything to do with hooks at all. react-testing-library is a nice addition that can help write integration tests for your hooked components.

This would function identically whether HexInput were written with hooks or as a function component. If you’re writing custom hooks and want to test the outside of a component context, react-hooks-testing-library looks promising to me.

Custom Hooks Possibilites

In addition to extracting logic to a reducer, you can also write your own hooks. It would be possible to write a custom hook … something like useColor that manages the context and sets appropriate values with callback / set functions.

In our input components we could use, for example, const { hex, updateHex } = useColor();. We would do this instead of using useContext directly. Here, we could use useState in our app component to maintain the hex and rgba values as we did in earlier examples.

The strategy you actually want to use will depend on your own needs and your own team. You may find it works better to create your own custom hook for maintaining state, or you may appreciate the reducer model where your update logic flows through the same code path and your state is represented by a function.

Bringing It All Together

Hopefully, this evolution of React hooks usage will help some people to better understand how hooks work and how you might use various different kinds of hooks for your React apps.

A functional example with all of the final examples of the above code files:

To recap on the hooks we went through:

  • useState is a hook that acts more or less as a replacement for the state property of class components. This allows you to maintain state in a function component. The useState function call returns the current state and a function that allows you to update the state and trigger another render similar to how this.setState would work in class components.
  • useContext allows components to get easy access to a context provider. This is similar to how Context.Consumer can work.
  • useEffect can be used to handle side effects that can happen when a component mounts or updates. You can also return a destructor function that will run when a component updates and unmounts, but we didn’t go through an example of that here. Side effects are typically asynchronous or not related directly to the logic of the rendering of components. It’s not quite analogous to componentDidMount et. al., but you would use it for similar purposes.
  • useReducer is a hook that allows us to encapsulate a state slice in a function that can be manipulated with actions that our functional components dispatch. In fact, other hooks are based on useReducer, which is a fairly foundational hook.
  • You can also create custom hooks that allow you to use hooks outside of the context of a component as long as the custom component itself is used in a component. This can help you extract out shared application logic.

You can test components with hooks in the same way you would test other function components. Don’t be intimidated by hooks when it comes to testing.

I’ve found that if you don’t understand hooks or a particular hook, the best thing you can do is throw together a small experiment that uses the hook. Try different things and see if you can get the hook to do what you expect. This is exactly what I did when building the color tool, and I think that it helped me get to a pretty good place in terms of code cleanliness thanks to React hooks.

Andrew Crites

Written by

Web developer and such