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