Beyond Prop Drilling in React: Using Context, Reducers, and Custom Hooks to Connect Components
By: Michael Landis
Tags: React Hook, React, Reactjs

The React documentation and introductory tutorials do a good job of explaining how Hooks work, but not how they work together.

We know how to use useState and useEffect to do something inside of a component. But what happens when we need to share state between components? How can we do so without turning our code into spaghetti?

I wrote this article to help close that gap. It’s long and meaty, but hopefully it gives you a sense of the different practices that go into creating cooperative component groups.

Before we go into our example, let’s introduce the players.

What’s Context?

If you’re already familiar with this, go ahead and skip it.

Context is a React feature that lets us store data that becomes available deeper down in the component tree. By using Context, we can avoid “prop drilling,” the practice where we pass properties from one component to the next, to the next, to the next, until we finally reach the component that actually uses those properties.

Context works by defining a Context, storing a value in its provider, and then using the Context in a child to retrieve the value:

This code returns <h1>Other components can get me through context!</h1> .

Some things to notice:

Context is created as a top-level variable in a module. This lets React track different types of Context (you can create many different types!) and make sure you get the type of Context you’re asking for.

The Context’s Provider makes its value available to any children who want to use that value.

When the value given to the Provider changes, any component using that Context gets rerendered.

Use Context with caution!

That last line is crucial. Every time the Provider’s value changes, every component that calls useContext for that Context will get rerendered. This means that Context isn’t useful everywhere.

If you use Context near the root of your application, the value property should only change sparingly. If its value is used throughout your site, you can find your site doing a ton of extra rerendering every time your Context’s value property changes. Top-level Context makes sense for things such as user profile information or style theming, since these rarely change. It’s literally “providing context” for your components. Tracking user interaction or API data storage/retrieval should use different techniques.

property should only change sparingly. If its value is used throughout your site, you can find your site doing a ton of extra rerendering every time your Context’s property changes. Top-level Context makes sense for things such as user profile information or style theming, since these rarely change. It’s literally “providing context” for your components. Tracking user interaction or API data storage/retrieval should use different techniques. You’ll need to be careful about tracking how value gets changed. This is especially true when you’re building object literals in components, or using filter or map array functions. These techniques create new objects and arrays every time, changing value every time, which can cause excessive rerendering and infinite loops. This needs to be carefully managed.

For situations that require manipulating and tracking data at a global level, use a library optimized for targeted state management, such as Redux. In spite of countless articles stating that Context can replace Redux, its optimized state management and render triggering is what makes it a viable global state manager. Even in a medium-sized application, React Context cannot manage frequent data changes at a global level without incurring serious performance issues.

But in a small group of cooperative components, Context will do the job. It can also be useful alongside Redux, for managing small groups of temporary, iterative state changes before storing the results in your global state.

What’s a Reducer?

If you’re already familiar with this, go ahead and skip this, too.

A reducer is a function that takes state and, given different actions to perform, returns new state. (It’s actually a reducer function that uses the Command pattern to implement changes, if you’re looking at this strictly from a design patterns perspective, but the React community describes this combination simply as a reducer.)

The idea is that you can send an action to your reducer along with the prior state, get new state back based upon that action, and do so in a way that keeps you from having to worry about how the new state is made. As long as you tell the reducer to “do X,” the reducer can do X any way it likes. It makes refactoring easier, and with newer techniques for building reducers, it makes it easier to add new actions to your reducer.

Reducers can feel cumbersome, especially if you follow any of the old-school Redux examples that use switch / case to handle the different commands. With simple state, it can be easier to make an object with different properties and update that object, or just handle the properties as separate units of state.

By using a reducer, however, your components don’t have to know anything about how to change your state. All the component cares about is the state, and that it can change the state. All of the mechanics for changing the state is stored in a single location, used by whichever components need it.

Also, instead of writing neverending switch / case lists, you can easily create a full-fledged reducer using createSlice from Redux Toolkit. Although made for Redux, this same function can make reducers for React’s useReducer Hook. It lets you define the reducer code for a given action, and it automatically creates the action for you. So much more satisfying than having to manage the actions yourself. I highly recommend it for creating production-ready reducer code, with or without Redux. We’ll show how we use it in the example below.

Using useReducer to manage complex state changes

While Redux is the go-to library for using a global state reducer, React provides its own mechanism, useReducer , to dispatch actions that update state based upon a reducer you provide. It takes initial state and a reducer that accepts your commands, and returns the most current state with a dispatcher. When an action is sent to the dispatcher, it gets to the reducer, which updates state based upon that data.

As an example, we can manage a form’s fields with something like the following:

In this example, all the code for updating the state is in exampleReducer.js , but Form.jsx doesn’t care. All it knows is that, as long as it dispatches the field’s name and value to the reducer through the changeField command, the reducer will update the value somehow. If the reducer needs to do any special checks or transformations, it can do them without the component worrying about it.

One of the nice things about useReducer is that, like the setter in useState , the dispatch function doesn’t change for the lifetime of the component. That means you can pass it anywhere through your component hierarchy, add it to effect dependencies, and be confident that it won’t trigger any extra renders.

Custom Hooks: Hiding the Clutter

If you’re already familiar with this, you know what to do.

Many developers new to React see custom Hooks as some form of Occult Arts, to be attempted only by the High Priests of React.

They’re really quite simple. They are helper functions, just like any other helper function, except that they use Hooks inside of them. Typically they combine React’s standard Hooks in ways to hide the clutter of using those standard Hooks. React-Redux’s useSelector , for example, is one of the more complex custom Hooks you’ll find, but it essentially uses the basic React Hooks to subscribe to state changes for a specific piece of content.

That’s the key to writing custom Hooks: If you want to create a helper function, and it needs to use Hooks, then you’ll need to make a custom Hook. If it doesn’t need Hooks, make it a simple function.

While you can certainly use a Hook to make reusable code, it can also be extremely helpful for keeping all of that code out of your components. Instead of having three or four Hooks managing state and effects in your component, you can have a single Hook that’s named something sensical, returning exactly the information you need, without having to worry about those effects and state management in your component itself. A hugely beneficial housekeeping technique.

Context, useReducer, and Custom Hooks: The Match Made in Heaven

Alright, if you skipped the last three sections, start here.

When useReducer is combined with context, you get a way to update your state from any component under your Provider without prop drilling. And you can hide the mess in a custom Hook.

We’ll show how that works with a simple searchable list. We’ll introduce the top-level component, then work down to show how the parts come together.

Here’s our top-level component:

We can see that we create our Context value with a custom hook, then output the list.

You’ll notice that there are three other components in our list — <Search/> , <Results/> and <Pagination/> — but none of them take any properties. They will take advantage of the value passed into <SearchableListContext.Provider/> to retrieve the Context’s value.

In addition, we have the Context we’re going to use:

Context is ridiculously easy to define. Frequently developers will embed it into the same component as where its Provider is used, but I tend to make it its own module, so that I can easily import it then access it via useContext(SearchableListContext) .

(This is a pretty simple example, where all of our child components are available to the parent component, so you could probably get away with not using Context. But as your collaborative component groups get bigger or become nested, these practices will become more useful.)

Side Note: Separation of Concerns and Making Stupid Components

You may notice that this component looks pretty simple. It provides a list and a search function to a hook, sends the hook’s results to a Context provider, and creates the structure for the list. That’s it.

This is by design. It can be incredibly easy to put all the code in one place, leading to 200-line components, mixing presentation, state management, interactivity and business logic in one place. Doing this can make it hard to keep track of what’s happening for any particular concern.

I tend to separate my code into the following layers:

State management

Business logic

Interactivity

Presentation

Even though the lines may blur between these layers at times, it lets me think of each layer independently. The state stuff doesn’t care why the state changes, it just does it. The business logic knows why the state needs to change, but doesn’t care how it changes. The interactivity layer knows it needs to send user responses to business logic, but doesn’t care how the business logic takes the interaction. And the presentation logic just outputs the results.

By splitting the code into these chunks, each chunk is manageable and understandable within its own context.

This is a huge part of component design: When I come back to the same code months later, how much code do I have to slog through before I can get a sense of what a component does? By splitting code and giving the chunks useful names, your future self won’t have to remember what the chunks do. They can look at the names in a small area of code to decide where to go next.

Hiding The Complexity in A Custom Hook

The main reason the top-level component is so simple is because we hide the complexity in the custom Hook. This is the main reason for single-use custom Hooks. Even if the Hook isn’t reusable, it isn’t complicating the presentation logic.

Let’s take a look at how this custom Hook builds the Context value we use for the rest of the component group:

You can see that the Hook does basically four things:

Set up a reducer to manage our state Generate values to use in our presentation Generate setters to manage the business logic and hide the state management layer from the interaction layer Bundle the values and setters into a single memoized object, for use in our Context

By putting all this code into a custom Hook, we can manage the scope of our business logic in one file. We don’t have to switch mental gears to manage state or presentation. If I’m in this file, I’m thinking about getters and setters, the business rules around how searches work and the data is returned, and that’s it. I’ll worry about what to do with this information elsewhere.

This sounds relatively simple, but it takes a lot of code, because we’re passing the results to a Context. And we don’t want infinite rerendering. So we need to memoize everything.

Creating Context Values and the Importance of useCallback and useMemo

useCallback and useMemo really don’t get the credit they deserve. Usually people talk about them in terms of performance optimization. But where they really shine is when you need to manage dependencies and rerendering. By memoizing objects and functions, you tell React to only recreate them when a dependency changes. That lets you control when effects are fired and Context gets updated.

In our example, we need to memoize the returned object. If we simply returned an object literal, it would be recreated every time the hook is called, forcing a rerender of all consumers every time, which would be wasteful at best or generate infinite renders at worst.

But memoizing an object means looking at all of the dependencies in that object. While we could cheat and add // eslint-disable-next-line react-deps/exhaustive-hooks to that last useMemo , we run the risk of not catching when something changes. By properly managing dependencies every time, we get a better understanding of why we break the rules when we absolutely must, rather than making it a thoughtless habit.

If we look at the list of dependencies in searchableListContextValue , we can see that pageCount , pageNumber , and searchText are primitives (strings, booleans, or numbers), but displayedList , setFullList , setSearchText and setPageNumber are objects or functions created inside of the hook. If we simply sent them into useMemo without also memoizing them, searchableListContextValue would still be recreated, as new versions of the objects/functions would be created every time. So we need to wrap those objects and functions in useMemo or useCallback , too.

When we start memoizing functions and objects, we end up creating a dependency tree, from object to object, until we finally reach primitives or parameters beyond our control. For searchableListContextValue , this tree looks something like this (pardon the ASCII art):

Dependency tree for searchableListContextValue

You can create one of these by looking at what you need to memoize, its dependencies, what dependencies need to be memoized, and so on. At the end of it, you should be able to lock down every dependency, either as a primitive value, a value only changed by an event, or a parameter changed according to the Hook or components’s contract.

In this case, we can narrow all of these dependencies down to things that change when our state changes, or when items or onSearch change. We can control our state, but we can’t control items or onSearch . We need to keep this in mind when we pass them into SearchableList in the first place.

We could remove onSearch from the dependency array for setFilteredList , but for an internal component group, we can also manage what we send for onSearch to prevent the dependency from being triggered too frequently. I’d avoid removing the dependency if possible, because if the provided onSearch function needed to change for some reason, we’d have no way of knowing.

If we were building a library, we’d have to manage this differently. useSelector from react-redux , for example, allows the selector to be any kind of function, but it checks the results instead, to see if the component should rerender.

Side Note: Naming is Hard, but Important.

Not gonna lie, useSearchableListContextValue is a long name. It’s not perfect, but it says exactly what it does.

When naming a function, variable or component, remember two things:

In six months, you’ll forget what the name means. Make sure it is descriptive enough that the name will tell you what it does, what it stands for. Make sure you can use the name verbally. How many times have you had to tell someone that they need to update numCtl or call setNetSysMgr() ? It’s a lot harder to do so than, say, numControls or setNetworkSystemManager() . Even consider the difference between pronouncing inputCount versus numInputs . If they represent the same thing, consider the one that rolls off the tongue more easily.

Code isn’t just written to tell a computer what to do. It’s written to tell another developer what you’re asking the computer to do. You’re writing an essay, in a language named JavaScript (or TypeScript or whatever), that other people need to be able to read. Some conventions, like Hooks being required to start with use , make this hard, but we have to give the best shot we can.

Managing State with a Reducer

We’ve looked at how the custom Hook connects our state to our components and adds business logic, but we haven’t looked at the state itself. Here’s where we define our reducer.

The following file uses Redux Toolkit’s createSlice to create a reducer as well as the commands/actions we call to update the reducer:

If you’ve looked at older Redux tutorials, this looks completely different from what you’re used to when it comes to defining reducers and actions. This is why createSlice is so useful. You create functions that update the state, give them names in an object literal, and voila! Instant reducer and actions.

If your action needs more than one argument, like setReducerSearch above, you create an object containing prepare and reducer functions, one to package the arguments, the other to update the state with that package.

These “case reducer” functions can either update the state in place, or return a brand new object. The reducer returned by createSlice lets you update things either way, and the result will be a new object.

You can read more about createSlice in the Redux Toolkit documentation. But what you can see is that each action function returned is tied to one of the functions in the reducers object, and that those functions update our state before it gets returned by the overall reducer .

This is a pretty simple piece of state: the full list, filtered list, page number and search text. But this model can be used with more complex state, such as a multi-tab modal or multi-step form, where it can maintain the data for each tab or step as well as identify which tab/step to present.

Once the reducer and its actions are defined, the reducer can be used with React’s useReducer Hook, and the actions called within its returned dispatch function.

Interaction and Presentation: The Components Using Context

Now that we’ve illustrated how the context value is created, now we’ll look at how it’s consumed. Because we’ve put the business logic into the custom Hook, and the state in the reducer, the context consumers end up being ridiculously simple.

Search

You can see that Search is getting the context we placed in the SearchableListContext.Provider instance further up the component tree. All Search cares about, all it should care about, is connecting the text field to the search text and its setter. The actual work happens inside the custom Hook.

Results

Again, the complexity is kept in the Hook. We’re simply outputting displayedItems . We don’t care how we decided which items should be displayed. We just care that we should display them.

Pagination

The final piece. Again, we just need the total page count, the current page number, and how to set a new page number. We don’t need to know how these values were derived, or how the setter works.

This component is a bit more complex, since we need to create an array from scratch in order to map the results, but it’s still not too crazy.

And there’s our cooperative component group! Linked by Context and a reducer, encapsulated with a custom hook.

You can play with this in its unstyled glory at https://codesandbox.io/s/searchablelist-9erd0.

tl;dr

After getting the program to do what you want, the second goal of development is writing code anyone can read months later. If it can’t be read, rewrite it.

Context lets you reduce the amount of code to track in any given component, but must be carefully managed to prevent spurious rerenders.

Separating concerns — presentation, interaction, business logic, and state — helps focus your thinking without going too many directions at once.

Custom Hooks let you keep your business code separate from your component code, making your components smaller and easier to understand.

Reducers let you keep complex state management in a single location. createSlice from Redux Toolkit is a great way to simplify building a reducer.

from Redux Toolkit is a great way to simplify building a reducer. Naming things is hard, but important for communication, and for reminding you of what you can ignore when rooting out a bug.

That’s it! Hopefully this gave you some ideas about how to begin building cooperative components.