Either by our own hand or by others, we have all seen code along the lines of:
const ComponentA = (props) => {
const [visible, setVisibility] = useState(true)
//...
return <ComponentB visible={visible} />
}
const ComponentB = (props) => {
//...
return <ComponentC visible={props.visible} />
}
const ComponentC = (props) => {
//...
if (!props.visible) return null
//...
}
Seems pretty innocent, right? Well, this seemingly reasonable example is actually an anti-pattern called Prop Drilling. Let's dive into what that means and how to avoid it!
What is Prop Drilling?
“Prop drilling” is the act of passing props from parent to child for multiple layers without modification.
In the example given above, the visible
prop was drilled from ComponentA to ComponentB to ComponentC without modification.
Why does this matter?
Prop Drilling is regarded as an anti-pattern for React since it can negatively impact the performance and maintainability of your code.
At a high level, Prop Drilling can cause performance issues since it can cause unnecessary re-rendering of components. This happens because React will re-render a component if any of its props change. If a component is passed a prop that it doesn't use, it will still re-render when that prop changes. This may not be a big deal for small applications, but can become a major issue for large applications that contain many expensive-to-render components.
On the maintainability side of things, Prop Drilling introduces dependencies between components that may not be obvious. This can make it difficult to refactor code quickly; which can be killer when developing new features.
If you wish to level up your React code, you should be aware of when you use Prop Drilling and be considerate of other approaches that can solve the problem at hand.
How does Prop Drilling creep into code?
Prop Drilling can be nefarious and creep into code unexpectedly. It requires vigilance in order to avoid it. Let's go through an example!
The following parts reference interactive examples that are available on ferreira.io. Gifs have been used in their place, but the sample app and code tabs for each interactive example are only available in the original published article.
Part 1: Setting Up Our Layout
To start out, let's provide some context for the example that we'll be using! We are building a simple web app with some containers, buttons, and text. Nothing is currently hooked up, so there isn't much functionality, but we can get a general sense of what the application, component hierarchy, and code looks like:
The cursor icon on the top-right of each component indicates that an onClick handler has been set for that component.
All of the components have been given a default onClick
handler for this first example. If you click on any of the components in the component hierarchy view, it will forcibly re-render that component which can be seen from the flash of green. You'll also see the number of times a component re-rendered in the bottom right-hand-side of each component.
Part 2: Adding Component State
Let's upgrade our application a little! We'll add some state to the Button component. This state will control a component's theme (which can be 'light' mode or 'dark' mode):
Reminder: clicking on the components from here on out will not forcibly re-render them (though they may re-render due to state update or changed props)
Wonderful! To start, we have set the onClick
for the Button component to toggle the theme. If you click on Button, you'll notice that the theme for that component changes and things look good!
Part 3: Pulling Up State
Let's now integrate some more components into our theme. We can start by pulling up the component state to the SettingsPage component so that we can pass the data down to its children. Then we can pass the theme's toggle function down so that our button components can update the theme:
Both IconButton and Button can update the theme in SettingsPage if you click on them.
Awesome, this works as expected! But interestingly enough, all of the child components of SettingsPage are re-rendering every time the theme changes.
This happens because React will re-render children components whenever a parent component re-renders. State updates will cause a component to re-render and thus this causes a cascade of re-renders.
Though this is generally really fast, it can cause performance problems for large applications. Sometimes, you only need data in certain places, and having everything else re-render just for a small change can be expensive. So let's try to do some optimizations!
Part 4: React memo
React provides a nice utility wrapper for components called memo
. This utility prevents a component from re-rendering unless the props passed in have changed. At first glance, this sounds perfect for us, so let's try to wrap all of our components with it:
Huh, we still see a lot of components re-rendering even though we only pass the theme's toggle function down, what gives?
Part 5: React useCallback
The important thing to realize is that memo
uses Object.is
for equality checking. In Javascript, primitives (like number, string, boolean) will be checked by value equality. However, objects, arrays, and functions are checked by reference equality. This means that code that defines a function will have a different reference to that function every time it's called. Thus reference equality checks will fail.
'123' == '123' // true (values are the same)
const A1 = { a: '123' }
A1 == A1 // true (references are the same)
const A2 = { a: '123' }
A2 == A2 // true (references are the same)
A1 == A2 // false (references are different even if contents are the same)
When our SettingsPage component re-renders, it ends up creating a new toggle function each time. This means that when memo
checks the props, it sees that the function reference is different and re-renders the component.
One way we can fix this issue is by using the useCallback
hook from React. By wrapping our toggle function in that, we can maintain a stable reference which prevents the components from re-rendering:
Amazing! Now only the SettingsPage component re-renders when its state changes. This saves us processing time since we don't need to re-render the child components anymore.
Part 6: Using Our Theme
Great, now that we have fixed our performance problem, let's try to re-add our prior functionality from above where we can use the current state of the theme to change the Button component. We naturally do this by passing the theme's value down through the Content component and into Button:
Great! We can now see the theme change in Button! Understandably, Button re-renders when this state changes. Unexpectedly though, Content also re-renders!
Why is this happening? Well, going back to what was said in the previous section, memo
prevents re-renders only if the props are equal. With useCallback
, we made sure our toggle function remains stable between renders, but our theme state still changes either way.
At a glance, this may feel inevitable, but we can actually improve this! You may wonder why, but if you imagine the component hierarchy being much larger, or if our Content component was very expensive to render, then avoiding re-renders can be very helpful.
Part 7: Replacing Component State With App State
Alright, let's finally wrap this example up! Now that we see how difficult it is to use component state that spans across our application, we can instead try to use application state instead. This generally takes the form of using a state management library (Redux being the most popular choice), but can be as simple as using React Context.
If we move our component state into app state and change our component code to connect to that app state directly (useSelector
in Redux, useContext
for React Context), we see something pretty awesome:
As you can see, we removed the state from SettingsPage and placed in into our App State. IconButton and Button can both update this state (by clicking on them) and Button reads from this state in order to show the appropriate theme to the user.
We have now eliminated all component re-renders except for Button (which is to be expected since it changes when the theme changes). From a performance perspective, this is the most ideal state we can get to.
Maintainability
A lot of the focus of the example above has been around creating performant React code. That said, avoiding Prop Drilling is also useful for code maintainability!
When I talk about maintainability, I'm referring to one's ability to extend code, refactor it, compose it, and generally maintain it as a product evolves over time. This is a broad category, but is very useful in order to avoid foot-guns, bugs, and more over time.
Returning to the code we had right before we pulled component state into app state (removing the performance improvements for clarity sake), we can see that toggleTheme
and theme
are passed down between components.
const Root = () => {
return (
<>
<SettingsPage />
<Modal />
</>
)
}
const Modal = (props) => {
// ...
}
const SettingsPage = () => {
const [theme, toggleTheme] = useTheme()
return (
<div>
<IconButton toggleTheme={toggleTheme} />
<Title />
<Content toggleTheme={toggleTheme} theme={theme} />
</div>
)
}
const IconButton = (props) => {
const { toggleTheme } = props
return (
<button onClick={toggleTheme}>
<Icon />
</button>
)
}
const Title = (props) => {
return (
<div>
<h1>Settings Page</h1>
</div>
)
}
const Content = (props) => {
const { toggleTheme, theme } = props
return (
<div>
<Text />
<Button theme={theme} toggleTheme={toggleTheme} />
</div>
)
}
const Text = (props) => {
return <p>Change Theme?</p>
}
const Button = (props) => {
const { toggleTheme, theme } = props
return (
<button onClick={toggleTheme} className={theme}>
Toggle
</button>
)
}
The reason this code isn't maintainable is that we have tied Content to both SettingsPage and Button through a dependency (via the props). In order to use Button anywhere else, we need to make sure that its new parent also passes down the same props that Content does.
To clarify this, let's use our example. What happens if we wanted to move Button to be a child of the Title component instead? Well, that change would look like the following.
const Root = () => {
return (
<>
<SettingsPage />
<Modal />
</>
)
}
const Modal = (props) => {
// ...
}
const SettingsPage = () => {
const [theme, toggleTheme] = useTheme()
return (
<div>
<IconButton toggleTheme={toggleTheme} />
- <Title />
- <Content toggleTheme={toggleTheme} theme={theme} />
+ <Title toggleTheme={toggleTheme} theme={theme} />
+ <Content />
</div>
)
}
const IconButton = (props) => {
const { toggleTheme } = props
return (
<button onClick={toggleTheme}>
<Icon />
</button>
)
}
const Title = (props) => {
+ const { toggleTheme, theme } = props
+
return (
<div>
<h1>Settings Page</h1>
+ <Button theme={theme} toggleTheme={toggleTheme} />
</div>
)
}
const Content = (props) => {
- const { toggleTheme, theme } = props
return (
<div>
<Text />
- <Button theme={theme} toggleTheme={toggleTheme} />
</div>
)
}
const Text = (props) => {
return <p>Change Theme?</p>
}
const Button = (props) => {
const { toggleTheme, theme } = props
return (
<button onClick={toggleTheme} className={theme}>
Toggle
</button>
)
}
As we can see from the code diff, we had to manage a few props and make sure they get passed through properly. In this example, it's not too difficult, but imagine if we needed to update several intermediate components in order to get things to work. Things can quickly get more complicated!
Now let's imagine we wanted to move Button to a completely different part of the component hierarchy. We would need to move the component state so that it exists within an ancestor component for all of its instances.
const Root = () => {
+ const [theme, toggleTheme] = useTheme()
+
return (
<>
- <SettingsPage />
- <Modal />
+ <SettingsPage toggleTheme={toggleTheme} theme={theme} />
+ <Modal theme={theme} />
</>
)
}
const Modal = (props) => {
// ...
}
const SettingsPage = () => {
- const [theme, toggleTheme] = useTheme()
+ const { toggleTheme, theme } = props
return (
<div>
<IconButton toggleTheme={toggleTheme} />
<Title />
<Content toggleTheme={toggleTheme} theme={theme} />
</div>
)
}
const IconButton = (props) => {
const { toggleTheme } = props
return (
<button onClick={toggleTheme}>
<Icon />
</button>
)
}
const Title = (props) => {
return (
<div>
<h1>Settings Page</h1>
</div>
)
}
const Content = (props) => {
const { toggleTheme, theme } = props
return (
<div>
<Text />
<Button theme={theme} toggleTheme={toggleTheme} />
</div>
)
}
const Text = (props) => {
return <p>Change Theme?</p>
}
const Button = (props) => {
const { toggleTheme, theme } = props
return (
<button onClick={toggleTheme} className={theme}>
Toggle
</button>
)
}
This example is basic, but in real-world scenarios, this potentially entails moving it up several layers of components. As we would expect, this can quickly get out of hand and it is much more difficult to maintain as the codebase changes. We've now modified several different components unrelated to the feature we were working on (not fun for code review) and introduced an increased risk of accidentally breaking something (not fun for on-call).
Now, what does it look like to change this code to use app state instead? Quite simply, it just takes moving the component in the right place. We've fully encapsulated the logic associated with the component to itself so that we're not polluting the rest of our application in the process.
const Root = () => {
return (
<>
<SettingsPage />
<Modal />
</>
)
}
const Modal = (props) => {
+ const theme = useSelector(themeSelector)
// ...
}
const SettingsPage = () => {
- const [theme, toggleTheme] = useTheme()
return (
<div>
- <IconButton toggleTheme={toggleTheme} />
+ <IconButton />
<Title />
- <Content toggleTheme={toggleTheme} theme={theme} />
+ <Content />
</div>
)
}
const IconButton = (props) => {
- const { toggleTheme } = props
+ const dispatch = useDispatch()
+ const toggleTheme = () => dispatch(toggleThemeAction)
return (
<button onClick={toggleTheme}>
<Icon />
</button>
)
}
const Title = (props) => {
return (
<div>
<h1>Settings Page</h1>
</div>
)
}
const Content = (props) => {
- const { toggleTheme, theme } = props
return (
<div>
<Text />
- <Button theme={theme} toggleTheme={toggleTheme} />
+ <Button />
</div>
)
}
const Text = (props) => {
return <p>Change Theme?</p>
}
const Button = (props) => {
- const { toggleTheme, theme } = props
+ const dispatch = useDispatch()
+ const theme = useSelector(themeSelector)
+ const toggleTheme = () => dispatch(toggleThemeAction)
return (
<button onClick={toggleTheme} className={theme}>
Toggle
</button>
)
}
Now, when we want to move the Button to be a child of the Title component, it's much more simple:
const Root = () => {
return (
<>
<SettingsPage />
<Modal />
</>
)
}
const Modal = (props) => {
const theme = useSelector(themeSelector)
// ...
}
const SettingsPage = () => {
return (
<div>
<IconButton />
<Title />
<Content />
</div>
)
}
const IconButton = (props) => {
const dispatch = useDispatch()
const toggleTheme = () => dispatch(toggleThemeAction)
return (
<button onClick={toggleTheme}>
<Icon />
</button>
)
}
const Title = (props) => {
return (
<div>
<h1>Settings Page</h1>
+ <Button />
</div>
)
}
const Content = (props) => {
return (
<div>
<Text />
- <Button />
</div>
)
}
const Text = (props) => {
return <p>Change Theme?</p>
}
const Button = (props) => {
const dispatch = useDispatch()
const theme = useSelector(themeSelector)
const toggleTheme = () => dispatch(toggleThemeAction)
return (
<button onClick={toggleTheme} className={theme}>
Toggle
</button>
)
}
A lot more simple, yeah? This code is more maintainable which allows us to easily extend, refactor, and compose as we build new features. This is a huge win for developer velocity and developer experience!
How to Create App State
Now, if you're new to React, you may be wondering how to create app state in your application. The best way to do so is to use a state management dependency to help out! The most popular option today is Redux and I'd encouraged you to use Redux Toolkit if you choose to go this route.
If your needs are simple, you can also just use React's builtin context. This is a part of React and doesn't need any additional dependencies to work. That said, there are similar performance issues from above that can occur when using objects and functions for the value of the context (due to reference equality checks).
Wrapping Up: Should I Ever Use Prop Drilling?
Now that we have reached the end of the article, you may be thinking that all forms of Prop Drilling are bad. This is where I tell you that there is time and place for everything. Prop Drilling can sometimes simplify code and definitely makes more sense in certain cases (like generic forms / components).
It's useful to recognize that React is very fast at re-rendering components, so it's important to profile your application to see if there are any performance issues before changing any code.
That said, I hope this has helped you better understand Prop Drilling and how it can impact your app's performance and maintainability! Cheers!
Top comments (0)