Having worked with React since the early days, I've seen a lot of ways or writing and structuring components. Whether it's the Component / Container (aka. smart / dumb) pattern, the state-management wars of the late teens, the sprinkling of Contexts everywhere or obviously hooks there's always some debate between teammates about libraries, lint rules or patterns. However, the one thing that's been there, quietly, for a long time now is the term "prop drilling".
Prop drilling is the idea of having to push a prop through a large component tree right down to a node that is many components deep through layers of components that don't care about that prop. For example:
const FilterButton = ({ onClick, ['aria-label']: filterButtonAriaLabel }) => (
<button aria-label={filterButtonAriaLabel} onClick={onClick}>
Filter
</button>
);
const FilterBox = ({ filterTitle, onFilterClick, filterButtonAriaLabel }) => (
<>
<div>{filterTitle}</div>
<FilterButton aria-label onClick={onFilterClick} />
</>
);
const Header = ({ filterTitle, onFilterClick, filterButtonAriaLabel ...loadMoreHeaderProps }) => (
<>
<FilterBox
filterTitle={filterTitle}
onFilterClick={onFilterClick}
filterButtonAriaLabel={filterButtonAriaLabel}
/>
{/* More components using more props */}
<>
);
In the example above you can see how the Header
component's prop count would grown indefinitely this way, and this seems to be what people refer to as "prop-drilling". Now there seem to be two issues (that are essentially the same issue) that people have with this:
- Firstly, there is a complaint, as mentioned above, that this component "shouldn't care" about those props and that you have to "drill" them through many layers of components. People often say that these props don't belong to the "domain" of this component. And certainly passing loads of different aria-label's down to the
Header
feels arduous. To counter this first issue I would say that this prop does belong to the domain of the component. It has to. TheFilterButton
is (albeit transitively) hard-coded into theHeader
component. Which moves us on to the second point. - The second complaint is that, even if people decide all those props do belong to the domain of the component, that the amount of props is a bad thing. In isolation, a large amount of configuration may be correct for a component but it does hint at a code-smell, and that code-smell has a solution.
There's a good article on what it is by Kent Dodds here, and this article is often widely cited but I've never personally agreed with some of the conclusions in the article and I don't think it goes far enough to explain patterns on how to scalably solve the problem.
I've seen many codebases solve this with with React's Context, and this is mentioned in Kent's article too. For example put some widely used variable in a context and use a hook to get hold of it. For example.
function PostCount() {
const posts = usePosts(); // Provided by a ContextProvider arbitraily far up the tree
const count = posts.length;
return <div>Post count: {count}</div>;
}
This solves one problem (loads of props) and replaces it with a bunch of other problems.
- Hidden dependencies. In order to test this you'll have to construct a
posts
array and wrap this function in a React Context, but you can't see that from the components function signature. - Separation of concerns. This components shouldn't have access to
posts
, it doesn't need it. It only needs a singlenumber
to render, and yet now it knows about all the posts. At which point this code could be refactored to the following and none of the callsites would get a choice in how to handle this change.
function PostCount() {
const posts = usePosts();
const count = posts.length;
const firstTitle = posts[0]?.title ?? 'Unknown'; // Some newly derive state from posts
return <><div>Post count: {count}</div><div>First post: {firstTitle}</div></>;
}
This reduces the value even more so for Typescript projects where, a change like this where you'd add a prop such as firstPostTitle
would alert you to all the callsites that needed to change and might make you aware that a certain useage of that comoponent wouldn't tolerate a change like this.
I would say that, in my experience, this approach generally doesn't scale and just solves the problem of a long props list - and it's still coupled to the posts
domain, just in a way that is non-obvious. But there is a very old solution that many people will have heard of (and probably negatively).
No, not Guice, or any other DI framework, or all the strong opinions Java devs hold on these frameworks, but the simple pattern of injecting isolated parts of your domain as a whole unit, into another scope that wants to use it.
Imagine the following class:
class AlbumValidator {
constructor(
validAlbumNameChars,
minAlbumThumbnailSize,
validTrackNameChars,
knownTracks,
valiArtistNameChars,
validArtistAlbums,
knownArtists
// etc.
) {
this.validAlbumNameChars = validAlbumNameChars;
this.minAlbumThumbnailSize = minAlbumThumbnailSize;
this.trackValidator = new TrackValidator(validTrackNameChars, knownTracks);
this.artistValidator = new ArtistValidator(
valiArtistNameChars,
validArtistAlbums,
knownArtists
);
}
validate(album) {
return (
this.#doValidate(album) &&
album.tracks.map((track) => this.trackValidator.validate(track)) &&
album.artists.map((artist) => this.artistValidator.validate(artist))
);
}
#doValidate(album) {
// interally validate the ablum fields
}
}
Similarly to the Header
component you could call this "arg-drilling" and it would be annoying for the same reasons listed above. However, there's already a solution to this, that solves those problems (and others around ease of testing, separation of concerns etc.). Injecting the dependencies. For example:
class AlbumValidator {
constructor(
validAlbumNameChars,
minAlbumThumbnailSize,
trackValidator,
artistValidator
) {
this.validAlbumNameChars = validAlbumNameChars;
this.minAlbumThumbnailSize = minAlbumThumbnailSize;
this.trackValidator = trackValidator;
this.artistValidator = artistValidator;
}
// methods as above
}
const trackValidator = new TrackValidator(validTrackNameChars, knownTracks);
const artistValidator = new ArtistValidator(
valiArtistNameChars,
validArtistAlbums,
knownArtists
);
const albumValidator = new AlbumValidator(
validAlbumNameChars,
minAlbumThumnbailSize,
trackValidator, // inject them in their entirity here
artistValidator // and here
);
This is just standard DI and removes the need for "arg-drilling" and, as easily as it can be applied to classes it can be applied to components.
const FilterButton = ({ onClick, ['aria-label']: filterButtonAriaLabel }) => (
<button aria-label={filterButtonAriaLabel} onClick={onClick}>
Filter
</button>
);
const FilterBox = ({ filterTitle, onFilterClick, filterButtonAriaLabel }) => (
<>
<div>{filterTitle}</div>
<FilterButton aria-label onClick={onFilterClick} />
</>
);
const Header = ({ filterBox ...loadMoreHeaderProps }) => (
<>
{filterBox} {/* Now we're injecting the whole box instead of all of it's props and constructing it internally */}
{/* More components using more props */}
<>
);
render(<Header filterBox={<FilterBox onFilterClick={() => {}} filterButtonAriaLabel="Filter for albums" />} />)
This also has the same benefits of dependency injection (because it is dependency injection!). A component can be though of as a single class with a render
method. The constructor is the JSX declaration <FilterBox />
and we can think of React internally calling a hidden render
method (although it didn't used to be hidden, and still isn't if you're using class components) on that, every time the component tree is re-rendered.
Now there are two things to note here:
- You don't need to do this for every component, or even most compoents, within a component. It's completely fine to have internal depedencies that are fixed and won't change. Commponents don't need to be infinitely extensible, the just need to be ergonomic and flexible enough for the uses that they were designed for.
- There's always some part of the application (i.e. some Component) that needs to construct these components and surely that component will have loads of props?! And well, I've found that this probably isn't the case. Often the arguments to these components are statically known at the page level or otherwise one prop at the page level can satisfy multiple props for child compopents, i.e.:
function Page({ posts }) {
const [isFilterActive, setIsFilterActive] = useState(false);
const filteredPost = isFilterActive ? post.filter(myFilter) : post;
const authors = [...new Set(posts.map(post => post.author))];
return (
<>
<Header
filterBox={
<FilterBox
onFilterClick={() => setIsFilterActive(prev => !prev)}
filterButtonAriaLabel="Filter for albums"
/>
}
/>
<PeopleSidebar title="Post authors" authors={authors.map(asPerson)}>
<PostCount count={filteredPost.length} />
<Posts posts={filteredPost} />
</>
);
}
This hypothetical component only need to take posts
as a prop and it can satisfy many of it's internal depednencies with that single prop. The rest here are titles and labels that are known statically in the scope of the page.
What am I not saying:
- Context is bad - it's fine and very useful for many cross-cutting conerns that do not cross the main application domain (i18n, auth etc.) but it should be used sparingly
- You must use DI for every component - this would be awful
To some degree teams need to work out how "pure" and "componentized" they want to be when it comes to their React codebase. There is a sliding scale of the method of injecting components and it will vary a lot, depending on how "static" a component is expected to be. I've actually found the easiest way to start is to not follow this pattern at all until it's needed. Then, when it is needed, instead of reaching for Context or just bloating props all over the place, think about moving certain components out of a parent and injecting it in! No need for prop-drilling or Context, just a nicely structured graph of dependencies.