Component State: Overcoming Tunnel Vision
Redux can eliminate the need for component state by storing all data in one immutable object. However Redux introduces new complexity to manage global state and actions. For domain resources (usually fetched over HTTP) a global store enables sophisticated caching strategies. But for application state, it’s often more trouble than it’s worth.
React lets us organize applications by the features in our domain (e.g. ProjectPage, Search, ValidatedField) rather than by technical patterns (e.g. views, containers, actions, reducers). Effective product teams work in terms of features, not abstract technical domains.
Deciding to work with component state puts pressure on our designs. Developers must decide which components should own state and then connect parent and child components to that state with data props and callbacks. Because components can make API calls, manage internal state, and render HTML they often grow with every new feature.
Let’s focus on one design pressure we often feel when using component state: tunneling data though layers of components.
Tunneling Application State
Most front-end developers know that they could forego global state if they were willing to pass every bit of state through every component. This practice is sometimes called “tunneling”.
Tunneling properties through components starts small. But every time a developer extracts an intermediate component, they must tunnel all the properties through that new component. It’s like they’re being punished every time they improve the design.
Let’s look at an example of a search component that follows a naive design path.
Already there’s a little boilerplate in our render
method. We’re mapping
names (e.g. onFilterClear
to this.handleFilterClear
) without providing
any new information. It’s redundant but it’s a reasonable way to expose a
public API to SearchLayout
.
Now let’s check out the SearchLayout
component:
SearchLayout
has some presentation responsibilities (notice the heading and the
horizontal rule), but it also has to divvy up props from its owner to its
children. This time we’re mapping props that our component doesn’t care about
to other props that our component doesn’t care about. This is where we start
to feel the “tunneling” pain.
Search, meet SearchField
SearchLayout
shouldn’t know how to connect the prop names between Search
and SearchField
. It should only care about presentation things: headings,
horizontal rules, and other stylistic elements.
So let’s pull the configuration of SearchField
and SearchResult
up the stack,
right where that information lives: inside Search
.
So what does SearchLayout
look like now?
SearchLayout
decides where to put the field
prop and results
prop, but
no longer needs to worry about the props exchanged between SearchField
,
SearchResults
, and Search
.
Is this actually better?
Are we really improving our design or just moving stuff around?
I argue this design is better because we’ve eliminated the repetition of
tunneling our properties through the SearchLayout
. We now map the Search
properties to owned components once rather than twice. We’ve also identified
an implicit responsibility (configuration) that was shared between two
components and is now confined to Search
.
However, if we look at the Search
component before and after this
refactoring we notice that it’s more complicated. Although SearchLayout
is
now dead simple, Search
knows about three components (SearchLayout
,
SearchField
, and SearchResults
) rather than one (SearchLayout
). Because
Search
is the top-level component in our Search
domain, it’s easy for
responsibilities to collect here.
Search
handles both the concern of querying and the concern of
coordinating communication between our search components. Let’s extract a
new component to handle our lower-level, “querying” concern.
Introducing the SearchQuery Component
An alternative, and probably a more popular option, is to create a higher
order component that accepts Search
as an argument and renders it with
a query
prop. I prefer the render callback because it declaratively shows
how all of our search components work together inside of Search
.
Notice that I cheated a little. Not only did I extract a component to handle
querying, I also bundling the props and callbacks into a domain object that
I’ll call query
inside of Search
.
Search
is looking pretty good at this point.
We set out to remove some prop
tunneling and at the same time dramatically
increased the flexibility of our design. It would be trivial to create
another Search
component that uses a different SearchQuery
component to
accommodate a different backend. We could inject a different presentation for
our search results into our SearchLayout
component. The flexibility comes
with a cost: separating concerns creates more abstract pieces to learn about.
But in this case, the level of abstraction in each component is more
consistent, making our components easier to understand.
Goodbye Tunneling
If you squint a little you can see query
as a little Redux bundle with one
immutable state object and 3 actions (setFilter, clearFilter, setTerm).
By creating a domain object and a component dedicated to composition, we removed the state tunneling pain. As a happy side-effect we created a design that is more flexible, has constant levels of abstraction, and is easier to test.
Next Post: Good Developer Tools Support Abstraction
Previous Post: React's Shallow Render - Tread Carefully