Disclaimer: This article is about Angular, as opposed to AngularJS. This means it applies for Angular 2.x, 4.x, and hopefully future versions.
If you’ve ever tried to write a reusable component in Angular, you probably had to project content inside of it. You discovered
<ng-content>, found a few blog posts about it, and got your component working. This article will walk you through the quirks and advanced use cases for content projection to answer questions that keep popping up on the Clarity team and apparently on Angular’s GitHub repository too.
Normally I would start this article by pointing you to the official documentation of the feature I’m describing, but for content projection it doesn’t exist yet… So let’s jump right in!
A simple example
We will use a single example throughout this article, showing different ways to project content and various edge cases. Because many of the questions are related to the component lifecycle in Angular, our main component will have a counter displaying the number of times it has been instantiated:
We will use this
Counter component and project it in any way we can think of into some variation of this
Wrapper component, which just projects it into a styled box:
Let’s just check these work as expected, by putting three counters inside the wrapper:
3 as expected. So far, so good.
From now on, we will test with a single counter for simplicity. So our default app’s HTML going forward will be:
Sometimes you want different children of your wrapper to be projected in different parts of your template. To handle this,
<ng-content> supports a
select attribute that lets you project specific content in specific places. This attribute takes a CSS selector (
[my-attribute], …) to match the children you want. If you include an
ng-content without a
select attribute, it will serve as a catch-all and will receive all children that did not match any of the other
ng-content elements. Long story short:
The counter is correctly projected into the second, blue box, while the child that is not a
Counter ends up in the catch-all red box. Note that the targeted
ng-content takes precedence over the catch-all, even if it’s after it in the template.
Sometimes your inner component is hidden within another larger component. Sometimes you just need to wrap it in an extra container to apply
ngSwitch. For whatever reason, it often happens that your inner component is not a direct child of the wrapper. To simulate that, let’s just wrap our
Counter component in an
<ng-container> and see what happens to our target projection:
Counter component is now projected in the red catch-all element, because the
ng-container around it doesn’t match the
select="counter" anymore. To remedy this, we have to use the
ngProjectAs attribute, which can be put on absolutely any element and lets it “disguise” any element for content projection purposes. It takes the exact same kind of selectors as the
select attribute on
So keeping our wrapper the same as before (with the blue and red boxes), we can now use this new attribute in our app:
The counter is back in the blue box, just like we wanted.
Time to poke and prod
Ok, we got the simpler cases working. But what happens if we think outside the box (wink wink)? Let’s start with a simple experiment: put two
<ng-content> blocks in our template with no selectors. What should happen? Will we end up with two counters or only one? If we end up with two, do they display
The answer is that we get a single counter in the last
<ng-content>, the other one is empty! Let’s experiment a bit more before we try to explain why. We’re getting to the example that generated the most questions on GitHub, by far: what if I wrap my
<ng-content> in an
At first glance, it seems to work fine. But if you toggle it on and off with the button, you’ll notice the counter doesn’t increase. This means our
Counter component is instantiated a single time — never destroyed and recreated. Isn’t that the opposite of what
*ngIf is supposed to do? Let us check with
*ngFor to see if we have the same problem:
Same thing as our multiple
<ng-content> case, only the last one gets a counter! Why is it not working as we expected?
<ng-content> does not “produce” content, it simply projects existing content. Think of it as some variation of
node.appendChild(el) or the well-known JQuery version
$(node).append(el): with these methods, the node is not cloned, it is simply moved to its new location. Because of this the lifecycle of the projected content is bound to where it is declared, not where it is displayed.
There are two reasons for this behavior: consistency of expectations and performance. What “consistency of expectations” means is that as a developer, I can read my app’s code and guess its behavior based on the code that I have written. Let’s say I wrote this piece of code:
Obviously the counter is going to be instantiated once. But now, let’s say instead of my static wrapper, I use one from a third-party library:
If the third-party library had the ability to control the lifecycle of my counter, I would have no way of knowing how many times it has been instantiated. The only way for me to know would be to look at the code of the third-party library, and be at the mercy of any internal changes they make. Enforcing the lifecycle to be bound to my app component instead of the wrapper’s means I can safely assume my counter will be instantiated a single time, without knowing anything about the actual code of the third party library.
The performance part is much more obvious. Because
ng-content only moves elements, it can be done at compile time rather than run time, which significantly reduces the work of the actual application (especially when compiling ahead of time, which angular-cli does by default).
To let the wrapper control the instantiation of its children, we need to give it a template for the content, rather than the content itself. This can be done in two ways: using the
<ng-template> element around our content, or using a structural directive with the “star” syntax, like
*myContent. For simplicity, we will use the
<ng-template> syntax in our examples, but you can find all the information you need about the star prefix here. Our new app looks like this:
The wrapper cannot use
<ng-content> anymore, since it receives a template. It needs to access the template with
@ContentChild, and use
ngTemplateOutlet to display it:
Our counter is now correctly incremented every time we hide it and show it again! Now let’s try again with
One counter in each box, displaying
3. Exactly what we were looking for!
Hopefully these explanations will be in the Angular documentation itself soon, but in the meantime I hope this deep dive into
<ng-content> will have answered most of your questions. In particular, it explains why Angular libraries request templates so often as opposed to just projecting templates. It does make the API slightly more verbose, but it opens many more possibilities to them: lazy-loading the content of tabs, duplicating titles in different places of a component, etc.
If you are curious and feel like a mad scientist today, go ahead and try more advanced experiments like projecting content inside of an
<ng-template>. The results are all consistent with the previous explanation but some of these tricky patterns do have useful applications. Maybe we’ll get to them in a future blog post!