In a previous series, we discussed some ways that FullStory uses the principles of composition, testability, and single responsibility to guide our React application architecture. In part 2 of that series, we explored FullStory’s React component categories, including “views,” “containers,” and “components.” We think this separation of concerns scales well for our large, complex codebase, but is it necessarily right for every application?
In this post, we’ll explore a lighter weight, hooks-based alternative to our “container” components that can achieve many of the same goals! Although we don’t use this pattern at FullStory, it may be useful in smaller codebases.
Container components
As a reminder, we define a "container" as a React component that has the single responsibility of passing data or state (generally fetched asynchronously) to its children. This generally includes things like code splitting, data fetching, or accessing shared UI states.
Our container components meet the following criteria:
A container should handle asynchronous actions
A container should handle accessing data and application level UI state or context
A container can be composed of views, other containers, and components
A container should not have any custom CSS styles
A container should have integration tests
React hooks
In 2018, React introduced hooks to help solve several common problems. Hooks provide a variety of benefits, but most prominently, they offer a way to reuse stateful logic between React components.
This benefit is quite similar to a key goal of our container components. In fact, reading through our container component criteria again, it seems clear that a hook can satisfy all of the same traits:
A hook should handle asynchronous actions
A hook should handle accessing data and application level UI state or context
A hook should be used in views and components
A hook should not have any custom CSS styles
A hook should have integration tests
So a hook could replace a container, but what would that hook look like, anyway?
Introducing the view model hook
Like the container that inspired it, a view model hook has the single responsibility of returning data or state to the component that calls it. As the name implies, the view model hooks apply the popular MVVM pattern to React components. Using a view model hook, we can separate the UI rendering logic from the state and handlers that affect it:
Just like useState
, useReducer
, and countless 3rd party hooks, our hook returns values and updaters: data
and handlers
. To use the hook, we simply call it within a component:
In contrast to the view model hook, our component is only responsible for rendering markup. It doesn’t care how the data
or handlers
, but it does care about how they’re translated into markup!
View model hooks vs containers
At this point, you may be asking yourself some valid questions like, “don’t these view model hooks look really similar to containers?” or “Why doesn’t FullStory simply use view model hooks?” These are great questions!
Organizing a codebase around the concept of React components and their corresponding view model hooks can work well, but the approach has tradeoffs that are worth discussing. Let’s see which pattern wins!
Separation of Concerns
Whether you prefer to use view model hooks or containers to separate concerns is mostly a matter of personal preference. Both patterns work well!
In codebases using the component/container paradigm, separation of concerns can be enforced with separate components
and containers
folders. Data and state management logic can be implemented in React components within a containers
folder, and UI rendering logic can be implemented in the components
folder.
Similarly, in codebases using the view model hook paradigm, separation of concerns can be enforced with separate view model hook and component files. Data and state management logic can be implemented in files named use[ComponentName].ts
, and UI rendering logic can be implemented in files named [ComponentName].tsx
. Both files would exist in a folder named [ComponentName]
in the components
directory.
So, which pattern better separates concerns? If we had to pick a winner, it could be argued that view logic is slightly more likely to leak into a container because it is a React component. However, it’s often just as easy to unintentionally couple your component hook to the markup through refs, element IDs, etc.
Let’s call it a toss up!
Composition
At first, improved composition seems like an obvious advantage of using view model hooks over containers. It’s much simpler to use multiple hooks within the same component than it is to pass props from multiple containers to the same component.
To illustrate, let’s consider two of our previous components: containers, CharacterListContainer
and CharacterCreateModalContainer
The
CharacterCreateModalContainer
renders theCharacterCreateForm
.The
CharacterListContainer
renders theCharacterList
.
Could we use both of them with one component? Not without major changes.
In contrast, it’s easy to use both a useCharacterForm
and useCharacterList
hook in the same component. See the following example:
So, why isn’t this a huge win for view model hooks? Ultimately, this distinction is not as useful as it may seem. As with containers, it’s often easier for each component to maintain a 1:1 relationship with each view model hook. That view model hook can then use other reusable hooks internally. In fact, FullStory already creates containers that depend on reusable hooks like this!
There’s no obvious winner here. This one is also a toss up!
Testing
Depending on how your codebase is structured, testing can be either a pro or a con for view model hooks. Let’s refactor our example from above to make it more testable:
First, let’s change our useCharacterList
hook to accept dependencies:
Next, we should update our CharacterList
component to take those dependencies as a prop:
Finally, we’ll use Storybook to create a visual test for our component:
The examples above illustrate why view model hooks aren’t suited to all apps. Their ease of use depends on how your app is structured. Testing is easy if your app meets the following criteria:
Your side effect logic is easy to mock, dependency inject, and accept as component props
Your component hierarchy is relatively flat and avoids deeply nested side effects within views
If the above are true, then the view model hook pattern will probably work well in your app! It will be easy to mock and render complex logic in Storybook, UI tests, etc. However, if your app doesn’t meet these criteria, then it will likely benefit from a more rigid separation between containers and components – this is the case at FullStory!
Conclusion
In this article, we talked about containers and component hooks. We discussed the pros and cons of each, and pointed out that both work well for different types of apps. And the verdict?
There isn’t a clear winner! Both patterns have a lot in common, but both are suited for different use cases.If your app is small, contains relatively flat component hierarchies, and consistent use of side effects, then the component hook pattern may work well for you! If your app contains large, deeply nested views that are difficult to mock, you might want to stick with a container approach.
As with most problems in software development, it’s probably best to choose the solution that addresses your particular needs!
---
If you love making smart design choices and solving complex engineering problems then check out our open roles!