Composite Component Pattern
Coupling Containers to their Concerns
This document was created to clarify details about the composite component pattern and component responsibilities. It also includes examples and testing guidelines. The patterns described here are intended to be general guidelines, not requirements, and are intended to be used when reasonable. The purpose of establishing these patterns is to provide enough structure for consistency but also allow flexibility for adaptation. Regardless of the author, code structure should feel consistent but not necessarily uniform.
Borrowing from composite materials, which combine two or more different materials to create a single superior material, the composite pattern combines connectors, components, blocks, and elements to create self-sufficient UI sections.
Composites are made up of three general component types: connectors, components, and blocks / elements. Their distinctions are based on their responsibilities:
- Connectors - responsible for application state
- Components - responsible for UI logic and markup structure
- Blocks & Elements - responsible for styling and formatting
Note: I am not one of Mr. Goldblum’s seven followers, but I needed to use the badge as an example for this post. This is actually a mock I created in Sketch. 👩🎨
ProfileCard composite fetches data for Jeff Goldblum and the current user via the connector, which it passes down to the component. The component determines whether the current user and Jeff follow each other and render the components and markup based on that logic. The blocks and elements format Mr. Goldblum’s information and apply styling.
Connectors are small containers that are collocated with the components they serve. They provide the application data and state needed for their client components. They also provide handlers for dispatching asynchronous actions. The implementation could be as small as a simple HOC, context provider, or use React hooks. It could also have a smarter implementation using a component with lifecycle methods.
To understand connectors it’s helpful to compare them to page-level containers. In the early days of Redux, page-level containers were a really common pattern. A single container would connect to the store and provide all the application state needed to render that page. This pattern is great for smaller applications without a lot of complex state to manage. Most components are agnostic about how they get their data and can be solely responsible for rendering logic.
The problem with this pattern is that it scales poorly if you need to handle a large amount of complex application state and data. The container becomes bloated and difficult to test and maintain.
Connectors follow many of the same patterns as page-level containers, but are focused on a smaller portion of UI. This simplifies their dependencies, and helps them better serve their components.
- Fetching application state for client components
- Shaping data for components
- Providing handlers for dispatching actions
ProfileCard needs Jeff Goldblum’s account info and list of followers as well as the same information for the current user from application state. The connector is responsible for fetching that data, shaping it for the component, and filtering out what the component doesn’t need.
While a unit test for a connector could verify that it provides the expected props in isolation, it’s probably more beneficial to write integration tests for the entire composite instead. As the connector’s primary purpose is to provide data for the components in the composite, an integration test would be more effective in validating that behavior.
Components are probably what you think of when you think about traditional React components. They are in charge of UI logic, establish markup structure, and might or might not be stateful.
NOTE: Because styling logic is handled by blocks and elements, components shouldn’t have related styles. Their only job is rendering logic. There might be Storybook for the component, but it shouldn’t think about styles itself.
- Handling UI logic
- Markup structure
- Passing props to children
ProfileCard needs to determine whether to display the “Follows You” badge on Jeff GoldBlum’s user card as well as the ‘Following’ text for the call-to-action button. After receiving the information it needs from the connector, it’s responsible for the logic to make those decisions and pass along information to its child components.
Because components are only in charge of rendering logic, you can focus your tests on that behavior. Shallow rendering and injecting props to mock out the various states of the component is sufficient.
Blocks & Elements
Inspired by BEM, blocks and elements are our lowest level component abstractions. They are solely responsible for styling and formatting markup. A block is itself a component, and it contains elements, which are the smallest parts of our UI. Some elements are standalone and do not live within the context of a block (
- Applying styles
- Formatting information (dates, currency, numbers, etc.)
ProfileCard needs to format the numbers displayed in the user info section. For numbers greater than 10,000, it should use the “10.1K” format, otherwise it should use comma-separated values. It also needs to style the
Card block and the elements contained within. The blocks and elements are responsible for managing these behaviors.
Because these components have little to no logic, they should not be unit tested. Regression tests with snapshots are sufficient, but are not required if they are captured in a higher-level snapshot.