An open API service indexing awesome lists of open source software.

https://github.com/mauriziocescon/ng-next

Just personal thoughts on the future of ng
https://github.com/mauriziocescon/ng-next

angular components signals

Last synced: 13 days ago
JSON representation

Just personal thoughts on the future of ng

Awesome Lists containing this project

README

          

# Anatomy of Signal Components

**⚠️ Note ⚠️: personal thoughts from a developer's perspective on [the future of Angular](https://myconf.dev/videos/2024-keynote-session-the-future-of-angular) at the template level.**

Highlights:

1. Building blocks as functions:
- `*.ng` files with template DSL (see [appendix](#appendix-co-located-templates-in-angular-via-ng-files)),
- `component`: a `setup` with scoped logic that returns a `template` or `{ template, expose }`,
- `directive`: a `setup` that can change the appearance or behavior of DOM elements,
- `derivation`: a factory for template-scoped computed values that requires DI,
- `fragment`: a way to capture some markup in the form of a function,
2. TS expressions with `{}`: bindings + text interpolation
3. Extra bindings for DOM elements: `bind:`, `on:`, `model:`, `class:`, `style:`, `animate:`, `use:`,
4. Hostless components + TS lexical scoping for templates,
5. Component inputs: lifted up + immediately available in setup and providers,
6. Composition with Fragments, Directives, and Forward Syntax,
7. Expose and Template Refs,
8. Dependency Injection Enhancements,
9. Final considerations (`!important`) + [`types`](https://github.com/mauriziocescon/ng-next/blob/main/types/ng-types.ts).

**Template syntax note**: the template syntax in the examples below resembles TSX syntactically but is Angular DSL, not JSX. It supports Angular control flow, directives, custom bindings, and an Angular-owned `IntrinsicElements` map for native tag typing.

Table of contents

- [Component structure and bindings](#component-structure-and-bindings)
- [Element directives](#element-directives)
- [Template-Scoped Derivations (`@derive`)](#template-scoped-derivations-derive)
- [Binding syntax helpers](#binding-syntax-helpers)
- [One-time bindings (`once:`)](#one-time-bindings-once)
- [Input-driven providers](#input-driven-providers)
- [Composition with Fragments, Directives, and Forward Syntax](#composition-with-fragments-directives-and-forward-syntax)
- [Expose and Template Refs](#expose-and-template-refs)
- [Dependency Injection Enhancements](#dependency-injection-enhancements)
- [Final considerations](#final-considerations)
- [Appendix: Co-located templates in Angular via `.ng` files](#appendix-co-located-templates-in-angular-via-ng-files)

## Component structure and bindings

`setup` runs once in an injection context. All bindings are wired and available immediately; destructuring is optional:

```ts
import { component, signal, linkedSignal, input, output } from '@angular/core';

export const TextSearch = component({
bindings: {
value: input.required(),
valueChange: output(),
},
setup: ({ value, valueChange }) => {
const text = linkedSignal(() => value());
const isDanger = signal(false);

function textChange() {
valueChange.emit(text());
}

/**
* Native elements are resolved through IntrinsicElements.
* This gives the compiler the element type plus valid DOM
* attributes, properties, and events.
*
* - 1way: bind:property={var} (bind: can be omitted)
* - 2way: model:property={var} (input / select / textarea)
* - events: on:event_name={handler}
*
* Invalid native bindings are compile-time errors:
* ‼️ // unknown attribute ‼️
* ‼️ // duplicate static/bound ‼️
* ‼️ // duplicate event ‼️
*
* Can use multiple class: and style:
* ✅
*/
return (
Text:

text.set('')}>
{'Reset ' + text()}

);
},
style: `
.danger {
color: red;
}
`,
});
```

Any component can be used in the template; `bind:`, `model:`, and `on:` behave the same as for native elements:

```ts
import { component, signal } from '@angular/core';
import { UserDetail, User } from './user-detail.ng';

export const UserDetailConsumer = component({
setup: () => {
const user = signal(/** ... **/);
const email = signal(/** ... **/);

function makeAdmin() {/** ... **/}

/**
* ⚠️ Must provide all required inputs / models ⚠️
*
* Invalid component bindings are compile-time errors:
* ‼️ // unknown binding ‼️
* ‼️ // duplicate binding ‼️
* ‼️ // duplicate binding ‼️
*
* Shouldn't use 'on' prefix with input / model / output
* ⚠️ ⚠️
*/
return (

);
},
});

// -- UserDetail -----------------------------------
import { component, input, model, output } from '@angular/core';

export interface User {/** ... **/}

export const UserDetail = component({
bindings: {
user: input.required(),
email: model(),
makeAdmin: output(),
},
setup: (bindings) => (
// bindings.user, bindings.email, bindings.makeAdmin
),
});
```

Lexical scoping resolves in this order: template → setup → functions, constants, enums, and interfaces imported in the file → global.

```ts
import { component } from '@angular/core';

enum Type {
Counter = 'counter',
Other = 'other',
}

const type = Type.Counter;

const counter = (value: number) => `Let's count till ${value}`;

export const Counter = component({
setup: () => (
@if (type === Type.Counter) {

{counter(5)}


} @else {
Empty
}
),
});
```

## Element directives

Change the appearance or behavior of DOM elements:

```ts
import { component, signal } from '@angular/core';
import { tooltip } from '@mylib/tooltip';

export const TextSearch = component({
setup: () => {
const text = signal('');
const message = signal('Message');

function valueChange() {/** ... **/}
function doSomething() {/** ... **/}

/**
* Encapsulation of directive data: use:directive(...)
* Any directive can be used directly in the template
*/
return (

Value: {text()}


);
},
});

// -- tooltip in @mylib/tooltip --------------------
import { directive, ref, input, output, inject, DestroyRef, Renderer2, afterRenderEffect } from '@angular/core';

export const tooltip = directive({
/**
* Host element constraint. When this directive is used on a
* native tag, the tag's IntrinsicElements entry supplies the
* concrete host type checked against this ref type.
*/
host: ref(),
bindings: {
message: input.required(),
dismiss: output(),
},
setup: ({ message, dismiss }, { host }) => {
const destroyRef = inject(DestroyRef);
const renderer = inject(Renderer2);

afterRenderEffect(() => {
const hostEl: HTMLElement | undefined = host();
// something with hostEl
});

destroyRef.onDestroy(() => {
// cleanup logic
});
},
});
```

## Template-Scoped Derivations (`@derive`)

`@derive` creates a template-scoped reactive computation, establishing an injection context before calling the derivation's `setup`. It follows the lifecycle of the enclosing view. Bindings are passed as named pairs `key={expr}`, not as a JS object literal.

```ts
import { component, derivation, computed, inject, input } from '@angular/core';
import { Item, PriceManager } from '@mylib/item';

const simulation = derivation({
bindings: {
/**
* Only inputs are allowed: a derivation has no DOM host,
* so there is no surface to emit outputs or sync models against
*/
item: input.required(),
qty: input.required(),
},
/**
* setup always returns Signal (e.g. computed)
*/
setup: ({ item, qty }) => {
const priceManager = inject(PriceManager);

return computed(() => priceManager.computePrice(item(), qty()));
},
});

export const PriceSimulator = component({
bindings: {
items: input.required(),
},
setup: ({ items }) => {
/**
* Any derivation can be used directly in the template via @derive
*
* price shares the @for embedded view scope and is created once,
* following its lifecycle. Same scope as @let, same lifetime as
* a pure pipe. Each row owns an independent instance. Not accessible
* outside its block.
*/
return (
@for (item of items(); track item.id) {
@derive price = simulation(item={item} qty={1});

{item.desc}

Price: {price()}

}
);
},
});
```

## Binding syntax helpers

- Literal form equivalence for inputs: literal attributes and literal expressions are equivalent for inputs: `prop="value"` and `prop={'value'}` produce the same input value.
- `:when`: conditionally applies a `use:` binding; sits outside the directive's inputs and cannot clash with them.

```ts
import { component, signal } from '@angular/core';
import { tooltip } from '@mylib/tooltip';

export const SearchBox = component({
setup: () => {
const text = signal('');
const showTip = signal(true);
const tip = signal('Type to search');

return (
// Literal equivalence: placeholder="Search" and placeholder={'Search'} are identical

);
},
});
```

## One-time bindings (`once:`)

`once:` lets the consumer freeze an input at creation time. The value is seeded once and never updated, even if the source signal changes later. Rules:

- `once:` applies only to inputs.
- `once:model:*` and `once:on:*` are compile-time errors.
- `once:prop` and `prop` together on the same element are a duplicate binding error.
- Literal input expressions are effectively one-time: `prop={'10'}` is semantically equivalent to `once:prop={v()}` where `v()` is a signal returning `'10'`.

```ts
import { component, signal } from '@angular/core';
import { UserDetail, User } from './user-detail.ng';

export const UserDetailConsumer = component({
setup: () => {
const user = signal(/** ... **/);
const email = signal(/** ... **/);

function makeAdmin() {/** ... **/}

return (

);
},
});
```

## Input-driven providers

Inputs hoisted to the component level for use in provider initialization (`providers` receives only inputs — not models or outputs):

```ts
import { component, linkedSignal, input, WritableSignal, provide, inject } from '@angular/core';

class CounterStore {
private readonly counter: WritableSignal;
readonly value = this.counter.asReadonly();

constructor(c = () => 0) {
this.counter = linkedSignal(() => c());
}

decrease() {/** ... **/}
increase() {/** ... **/}
}

export const Counter = component({
bindings: {
c: input.required(),
},
setup: () => {
const store = inject(CounterStore);

return (

Counter


Value: {store.value()}

store.decrease()}>-
store.increase()}>+
);
},
/**
* Only inputs are provided
*/
providers: ({ c }) => [
provide(CounterStore, () => new CounterStore(c)),
],
});
```

## Composition with Fragments, Directives, and Forward Syntax

Fragments are similar to [Svelte snippets](https://svelte.dev/docs/svelte/snippet): functions that return HTML markup. The returned markup is opaque — it cannot be manipulated like [React Children (legacy)](https://react.dev/reference/react/Children) or [Solid children](https://www.solidjs.com/tutorial/props_children).

`@forward()` designates where forwarded directives and bindings land. In `component.withDirectiveForwarding(...)`, it targets an element for directive passthrough. In `component.wrap(Target, ...)`, it forwards remaining bindings and directives to the wrapped component.

Implicit children fragment — placement, lifecycle, and binding context:

```ts
import { component, signal } from '@angular/core';
import { Menu, MenuItem } from '@mylib/menu';

export const MenuConsumer = component({
setup: () => {
const first = signal('First');
const second = signal('Second');

/**
* Markup inside a component tag implicitly becomes a children fragment
*/
return (

{first()}
{second()}

);
},
});

// -- Menu in @mylib/menu --------------------------
import { component, fragment } from '@angular/core';

export const Menu = component({
bindings: {
/**
* Provided by Angular from nested content (not bindable directly).
* This name is reserved by Angular.
*/
children: fragment(),
},
setup: ({ children }) => {
/** ... **/

/**
* No ng-container needed; full form: @render(fragment(), { injector })
*/
return (
@if (children) {
@render(children())
} @else {
Empty
}
);
},
});

export const MenuItem = component({
bindings: {
children: fragment.required(),
},
setup: ({ children }) => (
@render(children())
),
});
```

Customizing components:

```ts
import { component, signal } from '@angular/core';
import { Menu } from '@mylib/menu';
import { MyMenuItem } from './my-menu-item.ng';

export interface Item {
id: string;
desc: string;
}

export const MenuConsumer = component({
setup: () => {
const items = signal(/** ... **/);

/**
* Inline @fragment is auto-passed as the matching fragment input.
* Equivalent explicit form: declare @fragment outside, pass as menuItem={menuItem}.
*/
return (

@fragment menuItem(item: Item) {


{item.desc}

}

);
},
styleUrl: './menu-consumer.css',
});

// -- Menu in @mylib/menu --------------------------
import { component, input, fragment } from '@angular/core';

export const Menu = component({
bindings: {
items: input.required<{ id: string, desc: string }[]>(),
menuItem: fragment.required<[{ id: string, desc: string }]>(),
},
setup: ({ items, menuItem }) => (

Total items: {items().length}

@for (item of items(); track item.id) {
@render(menuItem(item))
}
),
});
```

Directives passed through a component and applied to an element:

```ts
import { component, signal } from '@angular/core';
import { Button } from '@mylib/button';
import { ripple } from '@mylib/ripple';
import { tooltip } from '@mylib/tooltip';

export const ButtonConsumer = component({
setup: () => {
const tooltipMsg = signal('');
const valid = signal(false);

function doSomething() {/** ... **/}

/**
* Type safety: Button forwards to HTMLButtonElement, so only
* directives whose host is assignable from HTMLButtonElement
* are accepted (e.g. host: ref() → compile error).
*
* The same directive cannot be applied more than once
* to the same component / element.
*/
return (

Click / Hover me

);
},
});

// -- button in @mylib/button --------------------
import { component, input, output, computed, fragment } from '@angular/core';

export const Button = component.withDirectiveForwarding({
bindings: {
type: input<'button' | 'submit' | 'reset'>('button'),
class: input(''),
style: input(''),
disabled: input(false),
click: output(),
children: fragment.required(),
},
setup: ({ type, class: className, style, disabled, click, children }) => {
const innerStyle = computed(() => `${style()}; color: red;`);

/**
* Directive forwarding: directives applied to are propagated to
* and instantiated on the internal (HTMLButtonElement) element.
*/
return (
click.emit()}>
@render(children())

);
},
});
```

Wrapping components and forwarding inputs, outputs, models, fragments, and directives:

```ts
import { component, signal, input, computed } from '@angular/core';
import { tooltip } from '@mylib/tooltip';
import { UserDetail, User } from './user-detail.ng';

export const UserDetailConsumer = component({
setup: () => {
const user = signal(/** ... **/);
const email = signal(/** ... **/);

function makeAdmin() {/** ... **/}

return (

);
},
});

/**
* Wrapper: selected bindings go to setup, remainder forwarded via @forward()
*/
export const UserDetailWrapper = component.wrap(UserDetail, {
bindings: {
user: input.required(),
},
setup: ({ user }) => {
const other = computed(() => /** something depending on user() or a default value **/);

return (

);
},
});

// -- UserDetail -----------------------------------
import { component, input, model, output, fragment } from '@angular/core';

export interface User {
name: string;
role: string;
}

export const UserDetail = component.withDirectiveForwarding({
bindings: {
user: input.required(),
email: model.required(),
makeAdmin: output(),
children: fragment(),
},
setup: ({ user, email, makeAdmin, children }) => (


{user().name}


Role: {user().role}

Email:

makeAdmin.emit()}>Make Admin

@render(children?.())


),
});
```

## Expose and Template Refs

`expose` is the public interface of `setup()` for refs. Components return it along with `template`; directives return it from `setup`.

`ref(Type)` → `Signal`, `refMany(Type)` → `Signal`; without `expose`, they resolve to `Signal` and `Signal`. Elements and components are bound with `ref={...}`, or with `use:...:ref={...}` for directives, and can be read after `afterNextRender`.

```ts
import { component, ref, refMany, signal, input, afterNextRender, Signal } from '@angular/core';
import { ripple } from '@mylib/ripple';
import { tooltip } from '@mylib/tooltip';

const Child = component({
setup: () => {
const text = signal('');
const _internal = signal(0); // not exposed

return {
template: (...),
// expose: component's public interface — only these are accessible via ref
expose: {
text: text.asReadonly(),
},
};
},
});

const Sibling = component({
bindings: {
childRef: input.required<{ text: Signal } | undefined>(),
},
setup: ({ childRef }) => (
childRef()?.text()}>Show text
),
});

export const Parent = component({
setup: () => {
// Native element: type explicit -> Signal.
// The template compiler checks ref={el} against the tag type from
// IntrinsicElements, so

is valid but
// is not.
const el = ref();
// Component: type inferred from expose → Signal<{ text: Signal } | undefined>
const child = ref(Child);
// Directive: type inferred from setup() return → Signal<{ toggle: () => void } | undefined>
const tlp = ref(tooltip);
// Multiple instances (e.g. inside @for) → Signal<{ text: Signal }[]>
const many = refMany(Child);

afterNextRender(() => {
// refs resolve here
});

return (


Something



tlp()?.toggle()}>Toggle tlp
);
},
});
```

## Dependency Injection Enhancements

Improved ergonomics for types and tokens:

```ts
import { component, inject, provide, injectionToken, input, signal } from '@angular/core';

/**
* Not provided in root by default: throws if not provided
* in the injector tree.
*
* factory = default factory used by the provide(compToken)
* shorthand — not a fallback
*/
const compToken = injectionToken({
debugName: 'compToken',
factory: () => {
const counter = signal(0);

return {
value: counter.asReadonly(),
decrease: () => {
counter.update(v => v - 1);
},
increase: () => {
counter.update(v => v + 1);
},
};
},
});

/**
* Auto-provided: factory invoked once at root scope —
* no need to provide it explicitly
*/
const rootToken = injectionToken({
debugName: 'rootToken',
autoProvided: true,
factory: () => {
const counter = signal(0);

return {
value: counter.asReadonly(),
decrease: () => {
counter.update(v => v - 1);
},
increase: () => {
counter.update(v => v + 1);
},
};
},
});

/**
* Token without factory: must use provide(token, factory)
* with an explicit factory. provide(otherCompToken) shorthand
* is a compile-time error.
*/
const otherCompToken = injectionToken({ debugName: 'otherCompToken' });

/**
* Multi token with factory: provide(multiToken) shorthand uses
* this factory — not a root default entry.
*/
const multiToken = injectionToken.multi({
debugName: 'multiToken',
factory: () => Math.random(),
});

class Store {}

export const Counter = component({
bindings: {
initialValue: input(),
},
setup: () => {
const rootCounter = inject(rootToken);
const compCounter = inject(compToken);
const multi = inject(multiToken); // array of numbers
const store = inject(Store);
/** ... **/
return (...);
},
providers: ({ initialValue }) => [
// provide compToken using the default factory (shorthand)
provide(compToken),

// multi with factory: shorthand works
provide(multiToken),
provide(multiToken),
provide(multiToken, () => 10),
provide(multiToken, () => initialValue()),

// token without factory: must use explicit factory form
provide(otherCompToken, () => ''),

// class
provide(Store, () => new Store()),
],
});
```

## Final considerations

### Concepts Impacted by These Changes

- `ng-content`: replaced by `fragments`,
- `ng-template` (`let-*` shorthands + `ngTemplateGuard_*`): likely replaced by `fragments`,
- structural directives: likely replaced by `fragments`,
- `pipes`: replaced by derivations — derivations cover the same transform use case and also support DI,
- `event delegation`: not explicitly considered, but it could fit as "special attributes" (`onClick`, ...) similarly to [Solid events](https://docs.solidjs.com/concepts/components/event-handlers),
- `@let`: unchanged,
- `bindings aliasing` at the setup level (ts destructuring),
- `directives` attached to the host (components): no longer possible, but directives can be passed in and attached to elements,
- `directive` types: since `host` is declared as a typed `ref` at the directive config level, static type checking is built in. For native tags, the target element type comes from `IntrinsicElements`, so directives can only be applied to compatible elements,
- `template reference variables`: likely replaced by `ref`,
- `queries`: likely replaced by `ref`; `ref` should be extended to cover programmatic component creation, but must not allow arbitrary `read` of providers from the injector tree (see [`viewChild abuses`](https://stackblitz.com/edit/stackblitz-starters-wkkqtd9j)),
- `component and directive injection`: the preferred interaction model is an explicit `ref` passed as an `input`. Nevertheless, with `ref`/`expose` in place, component and directive injection are safer by design — directive-to-directive and child-to-parent injection are established patterns worth keeping (see [`ngModel hijacking`](https://stackblitz.com/edit/stackblitz-starters-ezryrmmy) for the kind of abuse `expose` helps prevent). The trade-off is that some Angular-reserved names are necessary (`children`);
- `interface conformance`: opt-in via `satisfies` on `bindings` and `expose` — the same structural check that `implements` provides for classes.

### Notes

- other decorator properties: in this proposal, components and directives expose only `providers` and `setup` entries. However, `@Component` and `@Directive` have many more properties, some of which (like `preserveWhitespaces`) should probably remain. They are not covered here to avoid scope creep;
- `providers` defined at `directive` level: the added value is unclear, but the confusion they generate is well-documented; it is uncertain whether this concept remains meaningful;
- inputs and outputs can be reassigned inside the setup:
- `https://github.com/microsoft/TypeScript/issues/18497`,
- [`no-param-reassign`](https://eslint.org/docs/latest/rules/no-param-reassign).

### Pros and cons

Pros:

- familiar enough,
- not subject to typical single-file component (SFC) limitations,
- enforces a strict structure,
- AI agent-friendly,
- no `splitProps` drama 😅.

Cons:

- noticeable repetition in how bindings are declared and consumed,
- not plain TypeScript.

---

## Appendix: Co-located templates in Angular via `.ng` files

`tsx` does not support Angular control flow/directives today, so co-located templates likely require an Angular DSL in `*.ng` files plus dedicated tooling/parser support.

This is not only syntax preference: if co-location becomes default, losing `templateUrl` would be a regression for some teams. The intent is co-location without weakening Angular's structural model.

Key goals:
- template and setup live in the same lexical scope,
- tooling and agents get stable structural markers (`component`, `directive`, `derivation`, `fragment`),
- bindings remain explicit and statically typed,
- provider declarations remain separate from setup/template logic,
- providers can depend on inputs, but not on setup-local variables,
- component internals stay private — only what `expose` returns is reachable through `ref`.

This keeps the explicit contract model:
- `bindings` remain the canonical public API surface,
- Angular performs synchronization/wiring,
- strict checks happen at build time,
- `setup` runs once at component creation.

Interface conformance for `bindings` and `expose` stays opt-in via `satisfies`.

### Boilerplate tax — a known trade-off

Declaring a binding and then destructuring it in `setup` feels redundant for small components. This is a known tax of the format.

```ts
// Tiny — the tax is visible: ~5 lines of bindings for ~3 lines of logic
export const Badge = component({
bindings: {
label: input.required(),
variant: input<'info' | 'warn'>('info'),
},
setup: ({ label, variant }) => (
{label()}
),
});

// Medium — the same tax is a small fraction of the overall code
export const DataTable = component({
bindings: {
rows: input.required(),
selected: model(),
sort: output(),
rowTemplate: fragment<[Row]>(),
},
setup: ({ rows, selected, sort, rowTemplate }) => {
const sorted = linkedSignal(() => defaultSort(rows()));
const filter = signal('');
const filtered = computed(() => applyFilter(sorted(), filter()));
// ... 30+ lines of logic, handlers, derived state
return (...);
},
});
```

For medium and large components the binding declaration is a small fraction of the code, and the explicit contract pays for itself in readability, refactorability, and tooling support.

Three additional points:
- **Fairer comparison with other frameworks.** In React or Solid with TypeScript you typically write a separate `Props` interface that mirrors the component's accepted inputs — pure type-level boilerplate. Here, `bindings` serves double duty as both the type declaration *and* the runtime wiring. Counting the `Props` interface other frameworks require makes the math considerably more even.
- **Multi-component co-location.** Traditional SFCs (Vue, Svelte, etc.) map one component to one file. Splitting a growing component means creating a new file, moving markup, wiring imports, and updating the module graph — even for small, tightly coupled pieces. `.ng` files let you define helper components, fragments, and directives in the same file and extract them only when they earn their own module boundary.
- **Why not `defineBindings(...)` inside `setup`?** It would reduce repetition, but `providers` needs input access *before* `setup` runs — so it would require compiler hoisting magic or giving up input access in providers. It also introduces a second authoring style (à la Vue Options vs. Composition API) that tooling, docs, and developers all have to support.

One authoring format, explicit bindings, keeps the mental model simple — for humans and AI agents alike.