Keeping Your Angular Components DRY With Content Projection
Don’t Repeat Yourself
There is a concept in software development called DRY: Don’t Repeat Yourself. It means that you should reuse functionality rather than writing it from scratch. I often see this principle ignored when it comes to building components, though, which is odd, since that’s exactly what components are there for.
There are two things that change from one component to the next in your application: Behaviour and Content.
When behaviours change, you need to write new functionality to express those behaviours to the user. Two behaviours don’t often share the same content, but it is commonplace for the same behaviour to be used in multiple places.
Building a Modal
Take a simple use case — the humble modal. One modal alerts the user that they need to register and presents a form to do so. Another modal displays information about a product they clicked on.
The behaviour in both cases is similar: present content to the user in a modal that can be dismissed with a click. Each modal is styled the same, but the content is subtly different. Let’s take a look at how the code might looks for both:
Notice how the two are functionally the same, but the implementation differs slightly? One has an overlay, the other does not. One has a “dismiss” button, while the other has an “X” to close it. The markup and styling differs between the two. This is typical of projects that don’t reuse components, and leads to lack of consistent branding and user experience.
This is a reasonably contrived example, so the code is quite similar. It seems unlikely that the similarities here would go unnoticed. But in reality the modals would be more complex than this, and the differences more pronounced. Sometimes the differences between two components are so big that we don’t spot the similarities.
Let’s consider what we’d like these to look like instead.
This is much clearer and more consistent. We can set the title as a property and the content goes inside the “my-modal” tags. So how will this magic be accomplished?
The ng-Content Tag
This little baby is one of Angular’s content projection helpers. It acts as a placeholder for whatever you put inside the opening and closing tags of the current component’s declaration. Let’s take a look at the implementation for “my-modal”:
The key here is the ng-content tag, which makes it nice and simple to inject content. When your component is rendered, the ng-content tag is replaced by whatever you put in between the opening and closing tags of the component.
So, given the following component:
If I include that component like so:
Then it renders like so:
In this use case, we get by with plain property binding for the title, but what if we need something more complex? What if the header should include a subtitle? Let’s show an example of how that might work:
There is an extra ng-content tag here in the header, except that this has the “select” property. This tells the modal component to find anything matching that selector and include it here. You can use any CSS selector, which allows us to have multiple content slots in our modal quite easily.
Problem solved, right?
The ng-content tag is all well and good for a modal, but what about a list? I’ve often found myself repeating the same logic for lists simply because the list items were slightly different and I didn’t know any better. Despite recognising the similarities between two similar list components, I didn’t know of a way to make a single shared component that consolidated the shared logic.
It turns out that it’s quite simple to do. Here’s a very simple example that just captures the *ngFor logic:
Let’s walk through what’s happening:
- The list component includes an ng-template tag where the content for each item should be rendered
- The list’s ng-template tag provides an outlet, which tells it where to find the actual template. This part can be confusing, since you’d assume that a tag called ng-template is, in fact, a template. It is, but in this case it is more of a placeholder, deferring the actual template part to something else.
- We pass a templateRef into the component via the template input property. This is a reference to our actual template (the bit that contains the HTML we want to render for each item)
- In order to pass each item in the array to our template, we attach a context object to the inner ng-template.
- Any time we consume this list component, we create another ng-template, except this time we actually provide the markup we want to render. The item property is available via the context, so we can use that to display the row data, and we pass the templateRef by giving the template the productTemplate identifier.
All of the above seems like overkill just to consolidate a simple ngFor loop. But let’s look at a more complex example, based on a real project I worked on recently:
In this example, there is a lot more going on:
- There is now a default template, since most of the data I needed to put into these lists had similar core fields (name and description)
- Items can be deleted
- Items can provide a link to another page
- Odd numbered rows should have a different background colour
- Items can be grouped by category
- But sometimes I want a different template for each item
This particular project was actually a rebuild — the first time I tried this I actually repeated a lot of the above logic via copy & paste. This made maintaining those components very cumbersome and unreliable. When I had the chance to write it again from scratch, I made sure to do it right.
Imagine having all of that logic repeated half a dozen times throughout your app. The above component means that my template for a list is now only one line long. I can configure the list or easily extend the functionality of lists throughout the project. The overhead of creating a configurable component paid off pretty much as soon as I consumed the list component for the second time.
I’d be surprised if this is everything there is to know about content projection in Angular — I’m not smart enough to have learned it all. But it’s information that 2018 me would have loved to know, so 2019 me is writing it down for 2020 me in case I forget. Hopefully it makes your life a little easier, too!