Home › Learn
Components
How to write a component: inputs, outputs, slots, portals, references, error boundaries and scoped services.
A component#
A component is a JavaScript class plus a call to component(Class, meta). The class holds the state (signals) and the methods; meta says what the tag is called (selector), what the template is and which other components it uses (imports).
- Inputs: fields created with
input(initialValue). They are writable signals that the parent fills with[name]="expr"or with a plain attribute (name="text"). - Outputs: fields created with
output(). The child callsemit(value)and the parent listens with(name)="..."; the value arrives as$event. - Lifecycle:
ngOnInit()(before painting),ngAfterViewInit()(already in the DOM) andngOnDestroy()(when removed).this.$elis the host element. - Methods: they can be passed bare to the template (
(click)="save()"): they are already bound to the instance.
View the code
const { signal, component, mount, input, output } = AngeLite;
class Card {
title = input('');
closed = output();
close() { this.closed.emit('closed'); }
}
component(Card, {
selector: 'ui-card',
template: `
<div class="card">
<b>{{ title() }}</b> <button (click)="close()">×</button>
<div><ng-content></ng-content></div>
</div>`
});
class App {
name = signal('Ada');
last = signal('—');
log(e) { this.last.set(e); }
}
component(App, {
imports: [Card],
template: `
<ui-card [title]="'Profile of ' + name()" (closed)="log($event)">
Projected content: {{ name() }}
</ui-card>
<p>Last event: {{ last() }}</p>`
});
mount(App, root);In HTML always write <ui-card></ui-card>, not <ui-card />: the browser ignores the slash and "eats" whatever follows.
@Component({ selector, template }) does the same as component() if your tooling supports decorators. The types (Signal<T>, OutputEmitter<T>, OnInit…) ship with the package: see the reference.
Named slots#
The content you put between a component's tags is projected where <ng-content> is. If you need several areas, the component declares <ng-content select="title"> and whoever uses it marks the children with slot="title". Whatever has no slot goes to the default area. The content is evaluated in the parent's scope and stays reactive.
View the code
const { signal, component, mount } = AngeLite;
class Panel {}
component(Panel, {
selector: 'ui-panel',
template: `
<div class="card">
<header><ng-content select="title"></ng-content></header>
<section><ng-content></ng-content></section>
<footer><small><ng-content select="footer"></ng-content></small></footer>
</div>`
});
class App {
n = signal(1);
}
component(App, {
imports: [Panel],
template: `
<ui-panel>
<b slot="title">Order #{{ n() }}</b>
Panel body
<span slot="footer">{{ n() }} item(s)</span>
</ui-panel>
<button (click)="n.set(n() + 1)">+1 item</button>`
});
mount(App, root);Portals#
<ng-portal to="#target"> paints its content inside another element (document.body by default) but keeps it in the component's scope: it is for modals, menus and tooltips that must not get trapped by an overflow or a z-index. It cleans itself up when the block is destroyed. It is in the main package and in runtime-plus.
View the code
const { signal, component, mount } = AngeLite;
const target = document.createElement('div');
target.style.cssText = 'margin-top:10px;padding:10px;border:1px dashed currentColor';
target.id = 'portal-target';
target.textContent = 'Portal target: ';
root.after(target);
class App {
open = signal(false);
}
component(App, {
template: `
<button (click)="open.set(!open())">{{ open() ? 'Close' : 'Open' }} notice</button>
@if (open()) {
<ng-portal to="#portal-target"><b>Hello from elsewhere in the DOM!</b></ng-portal>
}`
});
mount(App, root);References to elements#
Mark an element with #name and receive it with viewChild('name'): a signal holding the element (or the component instance, if you put it on a component tag) that goes back to null when it leaves the DOM. It is how you focus a field, measure an element or call a method on the child.
View the code
const { component, mount, viewChild } = AngeLite;
class App {
field = viewChild('field');
focusIt() { this.field().focus(); }
}
component(App, {
template: `
<input #field placeholder="Type here">
<button (click)="focusIt()">Focus the field</button>`
});
mount(App, root);Handled errors#
If a component defines ngOnError(error, where), it receives the errors from its bindings and handlers and those of the content projected into it. That makes an error boundary: it shows a fallback instead of leaving a hole. Without ngOnError the error is logged to the console and the rest of the view keeps working.
View the code
const { signal, component, mount } = AngeLite;
class Boundary {
failure = signal(null);
ngOnError(error) { this.failure.set(error.message); }
}
component(Boundary, {
selector: 'error-boundary',
template: `
@if (failure()) {
<p class="card">Something went wrong: <b>{{ failure() }}</b></p>
} @else {
<ng-content></ng-content>
}`
});
class App {
broken() { throw new Error('invalid data'); }
}
component(App, {
imports: [Boundary],
template: `
<error-boundary><p>{{ broken() }}</p></error-boundary>
<p>The rest of the page keeps working.</p>`
});
mount(App, root);It catches errors from expressions, bindings and event handlers. It does not catch those thrown in the component's own constructor or ngOnInit: those propagate to whoever mounted it.
Services and dependency injection#
inject(Class) returns a shared instance of the class (created on first use): it is for services, global state and API clients. With provide(Class, value) you replace it, for example in a test.
View the code
const { signal, component, mount, inject } = AngeLite;
class CartService {
items = signal(0);
add() { this.items.update(n => n + 1); }
}
class AddButton {
cart = inject(CartService);
}
component(AddButton, {
selector: 'add-button',
template: `<button (click)="cart.add()">Add to cart</button>`
});
class Badge {
cart = inject(CartService);
}
component(Badge, {
selector: 'cart-badge',
template: `<b>🛒 {{ cart.items() }}</b>`
});
class Shop {}
component(Shop, {
imports: [AddButton, Badge],
template: `<add-button></add-button> <cart-badge></cart-badge>`
});
mount(Shop, root);Scoped services: providers#
If a component declares providers, the service is its own and its descendants', and every instance of the component has its own. A provider is the class, or { provide: Api, useClass | useValue | useFactory }. inject() looks first at the nearest component that declares it and, if none does, uses the shared instance. It works inside the constructor and field initializers.
View the code
const { signal, component, mount, inject } = AngeLite;
class Counter {
n = signal(0);
add() { this.n.update(v => v + 1); }
}
class Button {
c = inject(Counter); // the one from the nearest component that declares it
}
component(Button, { selector: 'my-button', template: `<button (click)="c.add()">add ({{ c.n() }})</button>` });
class Group {}
component(Group, {
selector: 'my-group',
providers: [Counter], // every <my-group> has its own Counter
imports: [Button],
template: `<my-button></my-button> <my-button></my-button>`
});
class App {}
component(App, { imports: [Group], template: `<p>Group A: <my-group></my-group></p><p>Group B: <my-group></my-group></p>` });
mount(App, root);