DEV Community

Hayden Braxton
Hayden Braxton

Posted on • Updated on

Headless Angular Components

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.

File Select

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.

`
  <button (click)="openFileSelectDialog()" [ngClass]="color">
    Pick a file
  </button>
`
export class FileSelectComponent {
  @Input() color = "primary";
}
Enter fullscreen mode Exit fullscreen mode

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 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.

`
<button
  *ngIf="!useCoolButton"
  (click)="openFileSelectDialog()"
  [ngClass]="color"
>
  Pick a file
</button>
<cool-button
  *ngIf="useCoolButton"
  (click)="openFileSelectDialog()"
>
  Pick a cool file
</cool-button>
`

export class FileSelectComponent {
  @Input() useCoolButton = false;
}
Enter fullscreen mode Exit fullscreen mode

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 about their specific UI needs.

The Problem with Customization

This is a slightly contrived example, as there's not but so 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?

Personally, I've never been a fan of this approach. Similar to the problem of shipping everyone else's UI in our app, we're still doing the same thing with css: shipping default css even though we've overridden it. Besides, we already have our own design system. We don't want to have to repeat those same styles when we already have styled UI components. Personally, I find it difficult to override CSS styles of a third-party component to make it match the rest of my UI exactly. Every time I have to do this, I feel like I'm struggling to bend someone else's CSS to my will. CSS variables remove some of the pain from this approach, but even then we can only customize what the original author exposes. Even if we can override CSS though, we still don't have control over the markup rendered on 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.

CallbackTemplateDirective

Notice first the *callbackTemplate directive.

<button
  *callbackTemplate="let context"
  class="primary"
  (click)="context.openFileSelectDialog()"
>
  pick a file
</button>
Enter fullscreen mode Exit fullscreen mode

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 CallbackTemplateDirective.

constructor(public template: TemplateRef<{ $implicit: TImplicitContext }>) {}
Enter fullscreen mode Exit fullscreen mode

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 …

FileSelectComponent

The real magic happens in the FileSelectComponent, itself. Notice first, the @ContentChilddecorator.

@ContentChild(CallbackTemplateDirective) callback: CallbackTemplateDirective;
Enter fullscreen mode Exit fullscreen mode

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 ngTemplateOutlet.

<ng-container
  [ngTemplateOutlet]="callback.template"
  [ngTemplateOutletContext]="templateContext"
></ng-container>
Enter fullscreen mode Exit fullscreen mode

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 [ngTemplateOutletContext]="templateContext".

templateContext = {
  $implicit: {
    // this has to be a lambda or else we get `this` problems
    openFileSelectDialog: () => this.openFileSelectDialog()
  }
};
Enter fullscreen mode Exit fullscreen mode

The $implicit key in the context object may look confusing. The value of this object is what's passed to our template 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.

<button
  *callbackTemplate="let context"
  class="primary"
  (click)="context.openFileSelectDialog()"
>
  pick a file
</button>
Enter fullscreen mode Exit fullscreen mode

When our *callbackTemplate is rendered, context is populated with the contents of templateContext.$implicit.

Now that the parent <file-select> component renders the TemplateRef from 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.

Final Thoughts

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 @Input() and @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.

Top comments (5)

Collapse
 
hiepxanh profile image
hiepxanh

wow, such a valuable post and very deep on the headlesss component. I remember about this post: medium.com/angular-in-depth/agnost...
And I read it about 10 times, but still very hard to understand.
But your post is simple, and easy to understand. I love that. Hope you have more post in the future.
Best wish in the new 2021 year.

Collapse
 
haydenbr profile image
Hayden Braxton

Thanks! Glad you found this useful. Please reach out if you have questions are additional ideas on this topic. Would love to chat. Happy new year to you as well.

Collapse
 
patelvimal profile image
vimal patel

Awesome article, Can you share how to use directly rather than a structural directive in this use case? How to pass context in that case?

Collapse
 
haydenbr profile image
Hayden Braxton

Thanks for reading 😀

Context is a feature of angular Templates, so if you want to pass context around you have to use templates at least. The structural directive is just kind of a syntactically nice way of creating a template and getting context. Unfortunately, templates aren't described super well in Angular documentation :(

Without a structural directive, you use templates directly. With that, you end up with

<ng-template let-context>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

instead of

<my-component *callbackTemplate="let context">
</my-component>
Enter fullscreen mode Exit fullscreen mode

If you want to see more about this strategy, you can read Stephen's article here. Under the hood, these are both doing the same thing, just a difference in syntax. But either way, you have to have templates.

Collapse
 
patelvimal profile image
vimal patel

Thank you so much for wonderful explanation.