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
- Host: GitHub
- URL: https://github.com/mauriziocescon/ng-next
- Owner: mauriziocescon
- Created: 2025-04-27T08:33:02.000Z (about 1 year ago)
- Default Branch: main
- Last Pushed: 2026-06-08T09:56:31.000Z (14 days ago)
- Last Synced: 2026-06-08T11:26:59.861Z (14 days ago)
- Topics: angular, components, signals
- Language: TypeScript
- Homepage:
- Size: 873 KB
- Stars: 1
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: readme.md
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) {
}
);
},
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.