Repository navigation
RFC: Signal-based inputs #17796
rkaraivanov
started this conversation in
Ideas
Replies: 1 comment
|
@rkaraivanov I would personally migrate apps I've created with What would be the difference to me, as a consumer, between Option C and Option F? |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
This RFC proposes how Ignite UI for Angular moves its component inputs from the
@Input()decorator to Angular signals. The library has 1,007@Input()declarations in 364 components and directives. Two migrations are done: the button directives (#17155) and the checkbox, switch and radio components (#17604). Both keep the public API and put asignal()behind each accessor. This RFC compares that pattern with the Angularinput()andmodel()APIs and recommends a path.Answer the numbered questions in the last section. Quote the question number in your reply.
Motivation
Signals are the reactive model of Angular. State that lives in signals makes
OnPushand zoneless change detection safe. A plain@Input()field is a property with no setter. A template binding writes it during the parent's change detection, marks the child view dirty and callsngOnChanges. A direct write from TypeScript,ref.checked = true, does none of that. Angular does not observe the property. With Zone.js andDefaultchange detection, every async task scheduled a pass that re-read the field, so the write was picked up. AnOnPushcomponent is refreshed only when it is marked dirty. A zoneless application runs a pass only when Angular is notified: a signal write,markForCheck(), a template event listener, an input binding orsetInput(). A direct write from asetTimeout, a third-party callback or a method that consumer code calls does not repaint.ngDoCheckandChangeDetectionStrategy.Eagerstill exist in zoneless. They run only inside a pass that something else scheduled, and they cost CPU on every pass.The library needs signal-backed inputs for three reasons:
OnPushcomponents are checked only when a signal they read changes, an input binding changes or an event fires. The radio group usedngDoCheckto synctabindexon every pass before refactor(checkbox,switch,radio): migrate to signals and OnPush #17604.computed()memoizes values such as host classes and ARIA attributes. Host bindings read signals directly.Renderer2writes and@HostBindingmembers are not necessary.The goal of this RFC is to pick one pattern for the 1,007 remaining
@Input()declarations. The pattern must be applied consistently so that consumers learn one rule.Current state
The library uses three patterns today.
Pattern 1:
@Input()on a plain field. This is the legacy pattern. It covers most of the 1,007@Input()declarations. The field does not notify change detection.Pattern 2:
@Input()on an accessor, backed by asignal(). The button directives (#17155) and the checkbox, switch and radio components (#17604) use this pattern. The public API does not change. Host bindings and templates read the backing signal.Pattern 3:
input()andmodel(). New components use the signal APIs directly.IgxGridLiteComponentdeclaresdataandautoGeneratewithinput()andsortingExpressionswithmodel().IgxVirtualScrollRecycleDirectiveusesinput.required(). 42 files useinput()today.The library also writes to inputs of other components from TypeScript.
IgxButtonGroupComponentsetsbutton.disabled.IgxRadioGroupDirectivesetsbutton.name,button.requiredandbutton.checked. Specs assign component properties directly in about 1,600 places. Neither compiles against a read-onlyinput()signal.Constraints
Each option must satisfy these constraints, or the RFC must say which one it breaks.
[checked]="x"andchecked="true"must work in all options. Angular binds@Input(),input()andmodel()the same way from a template.@ViewChildandviewChild()and writeref.checked = truein event handlers and methods. Angularinput()signals are read-only. Angular documents one write path for them: a template binding,inputBinding()at creation orComponentRef.setInput().ref.checkedis a value today. With a signal it is a function,ref.checked().booleanAttributeor a custom transform.model()does not support transforms. An attribute without a binding then arrives as an empty string.CheckboxBaseDirectivedeclare inputs that subclasses override. A subclass cannot re-declare an inheritedinput()with a different transform or type. The compiler rejects it with TS2416.checkedsetter calls theControlValueAccessorchange callback. A signal input has no setter. Side effects move toeffect()or to the write site.fixture.componentRef.setInput()instead.signal-input-migrationschematic skips accessor inputs and inputs that are written to. A consumer-facing break needs anng updatemigration inprojects/igniteui-angular/migrations.Options
Six options are possible. Options A, D and E keep the public API. Options B, C and F change it.
Option A:
@Input()accessor over asignal()This is the pattern of #17155 and #17604.
Pros:
required, inheritance overrides and setter side effects work.input()ormodel()needs no design work. The backing signal already exists.Cons:
@Input()field. 1.8 times the bytes ofinput().checkedfor consumers,_checked()for templates and host bindings.computed()oreffect()on component state.signal-input-migrationschematic skips accessor inputs. A later conversion needs our own schematic.Option B:
input()for every inputPros:
computed(() => checkbox.checked()).input.required()reports a missing binding at compile time.Cons:
ref.checked = truedoes not compile. In JavaScript, or throughany, the assignment replaces the signal with a boolean and the template throws at the next render.ref.checkedtoref.checked().ComponentRef.setInput(). A@ViewChildgives an instance, not aComponentRef.name,requiredandcheckedinto each radio. The radios must derive these values from the group through DI andcomputed().effect(). An effect runs during change detection, not at the time of the write.componentRef.setInput().ref.checked(). It cannot rewrite writes. The consumer must restructure the code.Option C:
model()for state,input()for configurationPros:
ref.checked.set(true).[(checked)]without extra code. The 44 existingxandxChangepairs in 26 files map directly tomodel().ref.x = vtoref.x.set(v).Cons:
<igx-checkbox checked>binds the empty string. 244 inputs use a transform today. AlinkedSignal()that coerces the value adds bytes and a second name.model()emitsxChangeon everyset(), also on programmatic writes. ExistingxChangeoutputs emit on user interaction only. The event contract changes.Option D: Option A plus a public read-only signal
Pros:
Cons:
input()leaves the second name as dead API or causes a second break.Option E: Generated accessors with
inputsmetadataA shared helper defines one accessor pair per name on the prototype.
Pros:
Cons:
declarefields have no body. IDE navigation and TypeDoc point to an empty declaration.no-inputs-metadata-propertyforbids theinputsarray.Option E is rejected. The comparison lists it for completeness.
Option F:
input()pluslinkedSignal()The template binds
[checked]. TypeScript readsref.checked()and writesref.checked.set(true). A new bound value replaces the local write.Pros:
xChangeoutput.ref.x = vtoref.x.set(v).Cons:
checkedInputin templates, not in the API docs.Comparison
@Input()fieldref.x = vsignal()ref.x = vinput()ComponentRef.setInput()onlymodel()+input()ref.x.set(v)formodel()input()onlyref.x = vref.x = vinput()+linkedSignal()ref.x.set(v)Over 1,007 inputs, Option A costs about 69 KB minified and 16 KB gzipped more than Option B. An application pays only for the components it imports. Option C has two values per cell:
model()first,input()second.Measurement method
A directive with five inputs, compiled with
ngc22.2.0 in full compilation mode, minified withesbuild0.28.2,ngDevModeset to false. Per-input bytes are the difference to an empty directive divided by five. Measured 2026-10-12.Recommendation
Adopt Option A for all existing inputs now. Move the public API to Option C or Option F in a later major release, after a separate decision.
Option A is the only option that satisfies constraints 2 to 7 and 10 without a major release. The byte cost is known. Options B, C and F break every consumer that reads or writes an input from TypeScript. That break is acceptable once, with a schematic, in a major release.
Rules for Option A
@Input()accessors orinput()/model(). Do not mix.input()andmodel(), asIgxGridLiteComponentdoes.protected readonly _name. Templates and host bindings read_name().computed(). Host bindings live inhostmetadata.@HostBindingandRenderer2writes are removed.untracked().OnPushin the same PR that makes all its state signal-backed.@hidden @internalmembers. Every removal has a changelog entry.Migration plan
OnPush.input()ormodel()from a per-input list. Decide state or configuration for each input. Validate on the demo application and the specs.ng updatemigration. The migration rewritesref.xtoref.x()andref.x = vtoref.x.set(v)wherexis amodel(). It inserts a TODO wherexis aninput().Step 4 needs its own RFC. This RFC decides steps 1 to 3.
Open questions
xChangeoutputs. Option F keeps transforms and costs more bytes.xChangeoutput exists. All other inputs are configuration.data, before the switch? Proposal: no.model()transforms before step 4? Without transforms, 244 inputs lose attribute coercion.xChangecontract change whenmodel()emits on programmatic writes? Proposal: document it as a breaking change in step 4.References
model()does not support transforms.@Inputremains fully supported.model()API.inputBinding(),twoWayBinding()andComponentRef.setInput().All reactions