Angelitedocs ENES

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 calls emit(value) and the parent listens with (name)="..."; the value arrives as $event.
  • Lifecycle: ngOnInit() (before painting), ngAfterViewInit() (already in the DOM) and ngOnDestroy() (when removed). this.$el is the host element.
  • Methods: they can be passed bare to the template ((click)="save()"): they are already bound to the instance.
Child component with input, output and ng-contentlive
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);
Custom tags do not self-close

In HTML always write <ui-card></ui-card>, not <ui-card />: the browser ignores the slash and "eats" whatever follows.

With TypeScript or decorators

@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.

Named slotslive
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.

A portal to another containerlive
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.

viewChildlive
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.

An error boundarylive
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);
What it catches and what it does not

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.

A service shared between componentslive
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.

providers: one service per componentlive
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);