Dynamic component rendering in AngularJS
We had a feature we needed in our AngularJS 1.5 app recently that required us to show different Angular components based on some data we got back from an API--basically we ask some questions and those questions can have different "types" of answers: number, text, list of values, multi-list of values, external API list, etc.
These answer types were a good candidate to keep encapsulated inside of Angular components: they manage their own state and just use component bindings to bind data or call the overall parent controller.
Hacking ng-include for fun and profit
So the way we got this working was based on this StackOverflow post.
This works because ng-include
directive takes a path to an HTML file. But $templateCache
can be used to manipulate the HTML cache and insert cache entries. In this case, you just insert a key with the name of your component and then you can use it as an expression in ng-include
. Now, the extra part is where that template cache item has to include your component directive:
$templateCache.put('widgetPie', '<widget-pie></widget-pie>');
It's worth noting the template inherits the context of the ng-include
, so you can pass bindings in like you would expect:
$templateCache.put('widgetPie', '<widget-pie data="$ctrl.someData"></widget-pie>');
In our case we had more templates than I wanted to write individual statements for, so the way we built up the $templateCache
was just like this:
import * as components from "./components";
angular
.module('app')
.run(function ($templateCache) {
_.values(components).forEach(({ type, include }) => {
const tag = `answer-${type}`
$templateCache.put(tag, `<${tag} question="question"></${tag}>`);
});
});
The cache key and template are generated by convention from the types of answers in each component (e.g. answer-text
). The question
binding is using the name of a known variable within the ng-repeat
in the parent template.
The index.js
file within the components
directory just exports all supported components:
export { component as text } from './answer-text.component'
export { component as number } from './answer-number.component'
export { component as lov } from './answer-lov.component'
Since we are using import * as components
all these get plopped into a components
object where each key is the exported name and the value is a slightly modified component object:
// answer-text.component.js
export var component = {
type: "text",
controller: () => {},
template: `<input ng-model="$ctrl.question" />`,
bindings: {
question: "<"
}
}
export var module = angular.module(`components.answers.${component.type}`, [])
.component(_.camelCase(`answer-${component.type}`), component)
.name;
Not shown here, but you need to make sure to add the component Angular module as a dependency somewhere in your app. That's what module
is for.
The type
property is for the purposes of the template cache. I'm using Lodash's _.camelCase
to create an Angular-compatible component name which is based on the type
.
Now we have a convention that uses the answer type to generate the HTML to inject via ng-include
and pass in common bindings to the components:
// parent.component.html
<div ng-repeat="question in $ctrl.questions">
<label>{{ question.title }}</label>
<ng-include src="answer-{{ question.answer_type }}"></ng-include>
</div>
Now depending on the answer_type
of the question, the appropriate answer component will be rendered.
It's probably possible to create a Angular directive that could make this easier but it's not so bad once you have the pattern.
PS. I'm using ES2015 and Webpack in my examples.
Trolling with React
Figuring out how to achieve this in AngularJS was a bit time consuming (I found the post after searching for awhile, then had to rig up what you saw above).
Even though I'm writing AngularJS more day to day, I can appreciate how much more productive I feel in React. What does it take to achieve the same pattern? I typed this up in a minute just now for the blog post:
const Parent = ({ questions, onAnswerChange }) =>
<div>
{questions.map(question =>
<div>
<label>{question.title}</label>
{React.createElement(
"Answer" + _.startCase(question.answer_type), { question, onAnswerChange })}
</div>
)}
</div>
const AnswerText = ({ question, onAnswerChange }) =>
<input
value={question.answer}
onChange={(e) => onAnswerChange(question, e.target.value)}
/>
I love React. It's just JavaScript (and JSX). No module loading, no dependency injection, just... components. Yes, state change is different (this isn't modifying question.answer
) but I like the separation of concerns.