Big thanks to Stephen Cooper (@SCooperDev) for reviewing this article
A headless component is one that provides behavior to its children, and allows the children to decide the actual UI to render while incorporating the behavior provided by the parent. Headless components encapsulate the implementation details of complex behaviors from the specific UI rendered on the page. By not being opinionated about the UI, headless components enable greater customization by letting us apply the reusable behaviors to a wider range of UI elements.
For the purposes of this article, when we say UI, we mean the visual elements the user sees on the page. Behavior refers to the actual functionality or effect that a user might see when interacting with elements on the page.
The concept of headless components has existed in the front end world for a couple years now, but has never really taken off in the Angular community. In React, Michael Jackson paved the way for headless components in his popular talk, "Never Write Another HoC," advocating for the Render Prop pattern, which is used to create headless React components. Kent C. Dodds later popularized the idea of headless components in React with the library, downshift, and his material on Advanced React Patterns. In 2018, Isaac Mann wrote a series of articles, translating Kent's Advanced React Patterns to Angular. Among the articles in that series, "Use <ng-template\>" shows how
<ng-template> can be used to replicate React's Render Prop pattern. Stephen Cooper further advanced this idea in his 2019 talk: "ngTemplateOutlet: The secret to customisation".
In this article, we explore an example of a headless component, and introduce a slightly different syntax for creating headless components in Angular. This is my effort to help further socialize the concept of headless components in the Angular community.
Suppose we have to build a file select for our app. The good news is, the browser does a lot of the heavy lifting for us, but we still have to do a little bit of work to harness the native file input and make it look and behave as we want. So we might build something like this.
Starting off, this works great. We have a simple file select, and users can select whatever files they want. As others start using the file select, though, they will inevitably want to customize the UI for their own needs. For the first change, suppose we have different brand colors, and while we only ever want the primary color, other people want to use the file select with other colors. Not a huge problem. We can add an
@Input() to control the button color.
Our component has increased slightly in complexity, but it still works and now everyone can use any brand color they want. At this point, it's still a pretty simple component, but we have more feature requests on the way!
Next, someone else on the team sees this file select interaction, and they want to use their <cool-button> component to trigger the file select dialog instead of a normal button. We could copy and paste the UI logic to programmatically trigger the click on the hidden input, but something seems wrong about straight copy and pasting, especially within the same component. So instead, we add another
@Input() to control which UI element opens the file select dialog.
At this point, it's starting to feel like this component is responsible for too much, but it gets the job done.
Next, someone wants the component to include a list of the selected files. If we were to satisfy this request, we might build out the markup for a list and add yet another
@Input() to show and hide the list. At this point, it's time to stop and rethink our approach to maintaining this component. Ideally, it would be nice to find a way to make it work for everyone else without us having to maintain their specific UI needs.
The Problem with Customization
This is a slightly contrived example, as there's not much variation in a file select, but this still demonstrates the problems we're trying to solve with headless components. We've all written or seen code that works like this. Whether it’s a universal feature like selecting files or something application specific, we’re often tempted to manage every possible component customization in the same place. So what's wrong with our approach to this component so far?
For starters, we don't want to ship everyone else's code in our app. We may never use some of the variations added to this component, but that code has to be included in our app anyways. It's also harder to manage the code with all possible use cases located in one place. Code changes overtime, and with all of these unrelated pieces of UI cobbled together, it's easy to accidentally break someone else's use case when making a seemingly unrelated change. And as more UI variations are added to this component, think about the length of this file. As this file gets longer, it will be harder to read and manage the code.
Maybe we made all of these change unnecessarily though? What if we allowed users to apply their own "theme" to this component by overriding default css?
Similar to the problem of shipping everyone else's UI in our app, we're still doing the same thing with CSS: shipping default styles even though we've overridden them. If we already have our own design system, it’s unnecessary to duplicate those same styles. Even when we can override styles though, we still can't control the markup rendered to the page. Some UI changes are difficult or impossible to make via CSS alone and require different markdown altogether.
So how can we provide this native file select behavior in a way that allows other developers to use their own UI?
Headless File Select
As it turns out, Angular gives us more tools than just
@Input() to customize components. Refactored into a headless component, this is how our file select looks now.
Let's step through the code to unpack how this works.
Notice first the
I'll typically name this directive something more application-specific, but for now we'll call it
callbackTemplate for clarity. (Soon, we'll see how it's in some ways analogous to a callback function). You can name this directive whatever suits you, though. The star on the front indicates that this is a structural directive. Structural directives are special in that they are responsible for deciding when to render the element to which they are applied. This is similar to how our friend
*ngIf works. Under the hood, the host element is actually wrapped up in an
<ng-template> and provided to the structural directive as a
TemplateRef, which the directive can render to the page.
But take a look at the class definition of
There's not much going on in this directive. All we have is a constructor with an injected
TemplateRef. So who actually renders the template? Notice that the access modifier is set to public …
The real magic happens in the
FileSelectComponent, itself. Notice first, the
That's a special decorator that tells Angular we want to get the first occurrence of
CallbackTemplateDirective within its content children. "What are content children?" you ask. A parent component's content children are any elements, components, or directives placed within the parent's starting and closing tags. The
@ContentChild decorator is kind of like Angular's version of
querySelector except that we can query for instances of components and directives in addition to native html elements.
Now that we have access to the
callbackTemplate directive, we also have access to its injected
TemplateRef because we made it public. Next, the file select component can render
callback.template to the page using
The beautiful thing here is
FileSelectComponent doesn't have to know what it's rendering. It just knows it has a template, and it knows where to render it. The user of the component decides what to render. We have a clear separation of concerns that allows us to render any UI to activate the file select.
But how does the custom UI actually open the dialog? When rendering a template, we can provide some context for the template to use
$implicit key in the context object may look confusing. The value of this object is what's passed to our template input variable
let context. We can actually add more keys to the context object, but that leads to a lot more syntax in the template. I prefer to put context data into
$implicit for simplicity because we can use any name we want for our template context variable.
*callbackTemplate is rendered,
context is populated with the contents of
Now that the parent
<file-select> component renders the
callbackTemplate and provides the method to open the file select dialog, the child content is free to open the file select dialog from any UI element it wants. From Isaac and Stephen's examples mentioned in the intro, we see that we can also use
<ng-template> directly rather than a structural directive, but I don't like the syntax as much. But either way, it's the same pattern using the same Angular features. Just different syntax.
Building components in this way is certainly a paradigm shift, but I hope you can see the value in being able to share UI behavior without polluting your code or forcing a specific UI. In Angular, we're used to thinking about
@Output() as the primary means for components to communicate with each other, but as we see here there exist other means by which we can create more flexible and more expressive component APIs.
I'll leave you with a final example to explore on your own. This example uses the same pattern to simplify creating and opening modals, which is typically a painful experience with most Angular libraries. For what it's worth, both the file select and the modal examples come from code that I've sent to production. The other developers I work with have also come to appreciate the simplicity of this approach. As you'll see from the modal example, the parent component might render some basic UI, so it's not strictly "headless". When building your API of components, you can decide where to draw the line between implementation details and customization based on what's appropriate for your application. A more specific headless component may only allow for a small amount of customization, while a more general-purpose headless component may not render anything at all to allow for full customization.