Synthetic view
Learn how you can dynamically synthesize views from templates generated on runtime.
Synthetic views in Aurelia 2 are a powerful feature that allows developers to create and manage views programmatically at runtime. Unlike traditional views in HTML templates, synthetic views offer complete control over view creation, binding, and lifecycle management.
When to Use Synthetic Views
Synthetic views are particularly useful when:
Rendering server-generated HTML with Aurelia bindings
Dynamically creating templates based on runtime data
Integrating with CMS or third-party content that needs Aurelia binding capabilities
Creating complex, programmatically-generated UIs
Basic Concepts
A synthetic view in Aurelia 2 consists of several key components:
Template Creation: A programmatically created template element containing your dynamic HTML
Render Location: A marker in the DOM where Aurelia will render your view
View Factory: Creates view instances from your template
Scope: Provides the binding context for your view
View Instance: The actual view that gets rendered and managed
Prerequisites
Before working with synthetic views, you should be familiar with:
Creating a new Aurelia app. This tutorial won't cover this; you can look at the other tutorials.
You have familiarized yourself with the Aurelia template syntax.
You have familiarized yourself with components in Aurelia.
You are familiar with Dependency Injection. You don't need to master it; you need to be familiar with its existence and why it matters in Aurelia.
Native Web APIs, such as
createElement
.
Getting Started
Here's a basic example of creating a synthetic view:
import {
convertToRenderLocation,
CustomElementDefinition,
ICustomElementController,
ISyntheticView
} from '@aurelia/runtime-html';
import { Scope } from '@aurelia/runtime';
import {
customElement,
IContainer,
ViewFactory,
IPlatform,
resolve
} from "aurelia";
@customElement({
name: 'synthetic-example',
template: '<div ref="container"></div>'
})
export class SyntheticExample {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
async attached() {
// 1. Create template
const template = this.platform.document.createElement('template');
template.innerHTML = `
<div>
<h1>\${title}</h1>
<button click.trigger="handleClick()">Click Me</button>
</div>
`;
// 2. Add to DOM and create render location
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
// 3. Create view factory
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'dynamic-view',
template
})
);
// 4. Create view instance
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
// 5. Create scope and activate view
const viewModel = {
title: 'Dynamic View',
handleClick: () => console.log('Clicked!')
};
await this.view.activate(
this.view,
this.$controller,
Scope.create(viewModel)
);
}
async detaching() {
if (this.view) {
await this.view.deactivate(this.view, this.$controller);
}
}
}
This code demonstrates the basic pattern for creating and managing synthetic views in Aurelia 2. Each step is essential:
Template Creation: Create a template element with your dynamic content
DOM Integration: Add the template to the DOM and create a render location
Factory Creation: Create a view factory from your template
View Creation: Create a view instance and set its location
Activation: Activate the view with a scope containing your view model
Cleanup: Properly deactivate the view when done
Working with Scopes and Bindings
Understanding how to handle scopes and bindings properly is crucial when creating synthetic views. The scope provides the context for all bindings within your dynamic template.
Basic Scope Handling
Here's how to create and use scopes effectively:
@customElement({
name: 'scope-example',
template: '<div ref="container"></div>'
})
export class ScopeExample {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
async attached() {
const template = this.platform.document.createElement('template');
template.innerHTML = `
<div>
<!-- Basic property binding -->
<h1>\${title}</h1>
<!-- Event binding -->
<button click.trigger="handleClick()">Click Me</button>
<!-- Two-way binding -->
<input value.bind="inputValue">
<p>You typed: \${inputValue}</p>
<!-- Repeater binding -->
<ul>
<li repeat.for="item of items">\${item.name}</li>
</ul>
</div>
`;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'scope-example-view',
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
// Create view model with reactive properties
const viewModel = {
title: 'Dynamic View with Bindings',
inputValue: '',
items: [
{ name: 'Item 1' },
{ name: 'Item 2' }
],
handleClick: () => {
console.log('Current input:', viewModel.inputValue);
}
};
await this.view.activate(
this.view,
this.$controller,
Scope.create(viewModel)
);
}
}
Working with Parent Scopes
Sometimes, you need to access the parent component's scope in your synthetic view. Here's how to do that:
@customElement({
name: 'parent-scope-example',
template: '<div ref="container"></div>'
})
export class ParentScopeExample {
// Parent component property
message = 'Hello from parent';
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
async attached() {
const template = this.platform.document.createElement('template');
template.innerHTML = `
<div>
<!-- Access parent scope -->
<h2>\${parentMessage}</h2>
<!-- Access local scope -->
<p>\${localMessage}</p>
<button click.trigger="handleClick()">
Update Messages
</button>
</div>
`;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'parent-scope-view',
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
// Create child scope with parent scope access
const childScope = Scope.create(
{
localMessage: 'Hello from child',
handleClick: () => {
// Can access both scopes
childScope.localMessage = 'Updated child message';
this.message = 'Updated parent message';
}
},
this.$controller.scope // Parent scope
);
await this.view.activate(
this.view,
this.$controller,
childScope
);
}
}
Handling Dynamic Updates
When your view model data changes, the bindings will automatically update. However, if you need to rebuild the view completely, you'll need to handle that manually:
@customElement({
name: 'dynamic-update-example',
template: '<div ref="container"></div>'
})
export class DynamicUpdateExample {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
async updateView(newData: any) {
// Deactivate existing view if it exists
if (this.view) {
await this.view.deactivate(this.view, this.$controller);
this.container.innerHTML = ''; // Clear container
}
// Create new view with updated data
const template = this.platform.document.createElement('template');
template.innerHTML = `
<div>
<h2>\${title}</h2>
<div repeat.for="item of items">
\${item.name}
</div>
</div>
`;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'dynamic-update-view',
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
await this.view.activate(
this.view,
this.$controller,
Scope.create(newData)
);
}
}
Best Practices for Scope Management
Cleanup: Always deactivate views when they're no longer needed
Scope Isolation: Create isolated scopes when you don't need parent scope access
Parent Scope Access: Use parent scopes judiciously to avoid tight coupling
Memory Management: Clear references to views and scopes when disposing
Error Handling: Wrap view creation and activation in try-catch blocks
async createView(data: any) {
try {
// ... view creation code ...
await this.view.activate(
this.view,
this.$controller,
Scope.create(data)
);
} catch (error) {
console.error('Failed to create view:', error);
// Handle error appropriately
}
}
async detaching() {
try {
if (this.view) {
await this.view.deactivate(this.view, this.$controller);
this.view = null;
}
} catch (error) {
console.error('Failed to cleanup view:', error);
}
}
Real-World Examples
1. Server-Rendered Content with Aurelia Bindings
This example shows how to take HTML content from a server (like a CMS) and make it interactive with Aurelia bindings:
@customElement({
name: 'cms-content',
template: '<div ref="container"></div>'
})
export class CmsContent {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
async attached() {
try {
// Fetch content from CMS/server
const response = await fetch('/api/content/page-123');
const html = await response.text();
await this.renderContent(html);
} catch (error) {
console.error('Failed to load content:', error);
}
}
private async renderContent(html: string) {
const template = this.platform.document.createElement('template');
// Server returns HTML with Aurelia binding expressions
// Example: <button click.trigger="handleAction('${id}')">...</button>
template.innerHTML = html;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'cms-content-view',
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
// Create view model with methods that the server-rendered content can bind to
const viewModel = {
handleAction: (id: string) => {
console.log('Action triggered:', id);
},
submitForm: async (formData: any) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData)
});
return response.json();
}
};
await this.view.activate(
this.view,
this.$controller,
Scope.create(viewModel)
);
}
async detaching() {
if (this.view) {
await this.view.deactivate(this.view, this.$controller);
}
}
}
2. Dynamic Form Builder with Validation
This example creates forms dynamically based on a schema, with validation:
interface FormField {
type: 'text' | 'number' | 'select' | 'date';
name: string;
label: string;
required?: boolean;
options?: { value: string; label: string; }[];
validation?: {
pattern?: string;
min?: number;
max?: number;
message?: string;
};
}
@customElement({
name: 'dynamic-form',
template: '<div ref="container"></div>'
})
export class DynamicForm {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
@bindable() schema: FormField[] = [];
@bindable() onSubmit: (data: any) => Promise<void>;
$controller!: ICustomElementController<this>;
async schemaChanged() {
await this.renderForm();
}
private generateFieldHtml(field: FormField): string {
const validationAttrs = field.validation ? `
pattern="${field.validation.pattern || ''}"
min="${field.validation.min || ''}"
max="${field.validation.max || ''}"
` : '';
switch (field.type) {
case 'select':
return `
<div class="form-group">
<label for="${field.name}">${field.label}</label>
<select
class="form-control \${errors.${field.name} ? 'is-invalid' : ''}"
id="${field.name}"
value.bind="formData.${field.name}"
required.bind="${field.required}"
>
<option value="">Select...</option>
${field.options?.map(opt =>
`<option value="${opt.value}">${opt.label}</option>`
).join('')}
</select>
<div class="invalid-feedback">
\${errors.${field.name}}
</div>
</div>
`;
default:
return `
<div class="form-group">
<label for="${field.name}">${field.label}</label>
<input
type="${field.type}"
class="form-control \${errors.${field.name} ? 'is-invalid' : ''}"
id="${field.name}"
value.bind="formData.${field.name}"
required.bind="${field.required}"
${validationAttrs}
>
<div class="invalid-feedback">
\${errors.${field.name}}
</div>
</div>
`;
}
}
private async renderForm() {
if (this.view) {
await this.view.deactivate(this.view, this.$controller);
this.container.innerHTML = '';
}
const template = this.platform.document.createElement('template');
template.innerHTML = `
<form submit.trigger="submitForm($event)">
${this.schema.map(field =>
this.generateFieldHtml(field)
).join('')}
<button type="submit" class="btn btn-primary">
Submit
</button>
</form>
`;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'dynamic-form-view',
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
const viewModel = {
formData: {},
errors: {},
validateField: (name: string, value: any) => {
const field = this.schema.find(f => f.name === name);
if (!field) return;
if (field.required && !value) {
viewModel.errors[name] = 'This field is required';
return false;
}
if (field.validation) {
// Add validation logic here
// Return false if validation fails
}
delete viewModel.errors[name];
return true;
},
submitForm: async (event: Event) => {
event.preventDefault();
// Validate all fields
let isValid = true;
for (const field of this.schema) {
const valid = viewModel.validateField(
field.name,
viewModel.formData[field.name]
);
if (!valid) isValid = false;
}
if (!isValid) return;
if (this.onSubmit) {
await this.onSubmit(viewModel.formData);
}
}
};
await this.view.activate(
this.view,
this.$controller,
Scope.create(viewModel)
);
}
}
Usage:
@customElement({
template: `
<dynamic-form
schema.bind="formSchema"
on-submit.call="handleSubmit($event)">
</dynamic-form>
`
})
export class FormPage {
formSchema: FormField[] = [
{
type: 'text',
name: 'username',
label: 'Username',
required: true,
validation: {
pattern: '^[a-zA-Z0-9]{3,}$',
message: 'Username must be at least 3 characters'
}
},
{
type: 'select',
name: 'role',
label: 'Role',
required: true,
options: [
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' }
]
}
];
async handleSubmit(formData: any) {
try {
await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(formData)
});
} catch (error) {
console.error('Submit failed:', error);
}
}
}
Advanced Topics and Patterns
1. Dynamic Component Loading with Lazy Loading
This example shows how to load and render components based on server configuration dynamically:
interface ComponentConfig {
type: string;
props: Record<string, any>;
template: string;
scriptUrl?: string;
}
@customElement({
name: 'component-loader',
template: '<div ref="container"></div>'
})
export class ComponentLoader {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
async loadComponent(config: ComponentConfig) {
try {
// Load external script if needed
if (config.scriptUrl) {
await this.loadScript(config.scriptUrl);
}
await this.renderComponent(config);
} catch (error) {
console.error('Failed to load component:', error);
}
}
private async loadScript(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
document.head.appendChild(script);
});
}
private async renderComponent(config: ComponentConfig) {
if (this.view) {
await this.view.deactivate(this.view, this.$controller);
this.container.innerHTML = '';
}
const template = this.platform.document.createElement('template');
template.innerHTML = config.template;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: `dynamic-component-${Date.now()}`,
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
await this.view.activate(
this.view,
this.$controller,
Scope.create(config.props)
);
}
}
2. Interactive Dashboard Builder
This example demonstrates building a dashboard with draggable widgets:
interface DashboardWidget {
id: string;
type: 'chart' | 'table' | 'metrics';
position: { x: number; y: number };
size: { width: number; height: number };
config: Record<string, any>;
}
@customElement({
name: 'dashboard-builder',
template: '<div ref="container" class="dashboard-container"></div>'
})
export class DashboardBuilder {
private container: HTMLElement;
private view: ISyntheticView;
private readonly platform = resolve(IPlatform);
private readonly diContainer = resolve(IContainer);
$controller!: ICustomElementController<this>;
private widgets: DashboardWidget[] = [];
private generateWidgetHtml(widget: DashboardWidget): string {
const style = `
position: absolute;
left: ${widget.position.x}px;
top: ${widget.position.y}px;
width: ${widget.size.width}px;
height: ${widget.size.height}px;
`;
return `
<div class="widget" style="${style}"
draggable="true"
dragstart.trigger="startDrag($event, '${widget.id}')"
dragend.trigger="endDrag($event)">
<div class="widget-header">
${widget.type.toUpperCase()}
<button click.trigger="removeWidget('${widget.id}')">×</button>
</div>
<div class="widget-content">
${this.generateWidgetContent(widget)}
</div>
</div>
`;
}
private generateWidgetContent(widget: DashboardWidget): string {
switch (widget.type) {
case 'chart':
return `
<canvas ref="chart-${widget.id}"
afterAttach.call="initChart($event, '${widget.id}')">
</canvas>
`;
case 'table':
return `
<table class="table">
<thead>
<tr>
${widget.config.columns.map(col =>
`<th>${col.header}</th>`
).join('')}
</tr>
</thead>
<tbody>
<tr repeat.for="row of widget.config.data">
${widget.config.columns.map(col =>
`<td>\${row[col.field]}</td>`
).join('')}
</tr>
</tbody>
</table>
`;
case 'metrics':
return `
<div class="metrics-grid">
${widget.config.metrics.map(metric => `
<div class="metric-card">
<div class="metric-value">\${${metric.value}}</div>
<div class="metric-label">${metric.label}</div>
</div>
`).join('')}
</div>
`;
default:
return '';
}
}
async attached() {
await this.renderDashboard();
}
private async renderDashboard() {
const template = this.platform.document.createElement('template');
template.innerHTML = `
<div class="dashboard">
${this.widgets.map(widget =>
this.generateWidgetHtml(widget)
).join('')}
<div class="dashboard-controls">
<button click.trigger="addWidget('chart')">Add Chart</button>
<button click.trigger="addWidget('table')">Add Table</button>
<button click.trigger="addWidget('metrics')">Add Metrics</button>
<button click.trigger="saveDashboard()">Save Layout</button>
</div>
</div>
`;
this.container.appendChild(template);
const renderLocation = convertToRenderLocation(template);
const factory = new ViewFactory(
this.diContainer,
CustomElementDefinition.create({
name: 'dashboard-view',
template
})
);
this.view = factory.create(this.$controller)
.setLocation(renderLocation);
const viewModel = {
widgets: this.widgets,
startDrag: (event: DragEvent, widgetId: string) => {
event.dataTransfer.setData('widgetId', widgetId);
},
endDrag: (event: DragEvent) => {
const widgetId = event.dataTransfer.getData('widgetId');
const widget = this.widgets.find(w => w.id === widgetId);
if (widget) {
widget.position = {
x: event.clientX - this.container.offsetLeft,
y: event.clientY - this.container.offsetTop
};
this.renderDashboard();
}
},
addWidget: async (type: DashboardWidget['type']) => {
const widget: DashboardWidget = {
id: `widget-${Date.now()}`,
type,
position: { x: 0, y: 0 },
size: { width: 300, height: 200 },
config: await this.getDefaultConfig(type)
};
this.widgets.push(widget);
await this.renderDashboard();
},
removeWidget: async (widgetId: string) => {
this.widgets = this.widgets.filter(w => w.id !== widgetId);
await this.renderDashboard();
},
initChart: (element: HTMLCanvasElement, widgetId: string) => {
const widget = this.widgets.find(w => w.id === widgetId);
if (widget && widget.type === 'chart') {
// Initialize chart using your preferred library
// Example with Chart.js:
new Chart(element, widget.config.chartConfig);
}
},
saveDashboard: async () => {
try {
await fetch('/api/dashboard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.widgets)
});
} catch (error) {
console.error('Failed to save dashboard:', error);
}
}
};
await this.view.activate(
this.view,
this.$controller,
Scope.create(viewModel)
);
}
private async getDefaultConfig(type: DashboardWidget['type']) {
// Return default configuration based on widget type
switch (type) {
case 'chart':
return {
chartConfig: {
// Default chart configuration
}
};
case 'table':
return {
columns: [
{ field: 'id', header: 'ID' },
{ field: 'name', header: 'Name' }
],
data: []
};
case 'metrics':
return {
metrics: [
{ label: 'Total', value: 0 },
{ label: 'Average', value: 0 }
]
};
}
}
}
Last updated