angular-signals-migration
Use this skill when the user asks to "migrate to signals", "convert @Input to signal", "convert @Output to output()", "convert @ViewChild to viewChild()", "signal migration", "input() migration", "viewChild migration", or when working with Angular signal patterns. Covers all 5 migration patterns (simple input, alias+local-copy, model, linkedSignal, watchedInputs), ViewChild static timing constraints, input classification by template binding, and critical anti-patterns. Encodes RULE 1 (input mutation pre-scan), RULE 2 (atomic complex migration), RULE 7 (missing signal () verification), and RULE 9 (ViewChild static timing).
SKILL.md
| Name | angular-signals-migration |
| Description | Use this skill when the user asks to "migrate to signals", "convert @Input to signal", "convert @Output to output()", "convert @ViewChild to viewChild()", "signal migration", "input() migration", "viewChild migration", or when working with Angular signal patterns. Covers all 5 migration patterns (simple input, alias+local-copy, model, linkedSignal, watchedInputs), ViewChild static timing constraints, input classification by template binding, and critical anti-patterns. Encodes RULE 1 (input mutation pre-scan), RULE 2 (atomic complex migration), RULE 7 (missing signal () verification), and RULE 9 (ViewChild static timing). |
name: Angular Signals Migration description: Use this skill when the user asks to "migrate to signals", "convert @Input to signal", "convert @Output to output()", "convert @ViewChild to viewChild()", "signal migration", "input() migration", "viewChild migration", or when working with Angular signal patterns. Covers all 5 migration patterns (simple input, alias+local-copy, model, linkedSignal, watchedInputs), ViewChild migration (viewChild.required for static queries), input classification by template binding, and critical anti-patterns. Encodes RULE 1 (input mutation pre-scan), RULE 2 (atomic complex migration), and RULE 7 (missing signal () verification). version: 1.0.0
Angular Signals Migration
Purpose
Guide the migration of @Input(), @Output(), @ViewChild, and ngOnChanges to Angular's signal-based APIs (input(), output(), viewChild(), model(), computed(), effect()). This skill prevents the common mistakes that caused ~23% rework in a real 93-commit upgrade.
ViewChild Migration (RULE 9)
Conversion Table
| Decorator Pattern | Signal Equivalent |
|---|---|
@ViewChild('ref') (non-static) | viewChild<T>('ref') |
@ViewChild('ref', { static: true }) | viewChild.required<T>('ref') |
@ViewChildren(...) | viewChildren<T>(...) |
// BEFORE
@ViewChild('modal') modal: SoftModalComponent;
@ViewChild('tpl', { static: true }) tpl: TemplateRef<any>;
// AFTER
modal = viewChild<SoftModalComponent>('modal');
tpl = viewChild.required<TemplateRef<any>>('tpl');
{ static: true }→ useviewChild.required()(available immediately)- Template access changes:
this.modal.open()→this.modal().open()
CRITICAL: Pre-Migration Scan (RULE 1)
BEFORE running any signal migration tool, classify every component's inputs:
Step 1: Check Template Binding
# For each @Input property, find how it's used in templates
grep -r "propertyName" --include="*.html" src/ projects/
| Template Binding | Signal Type | Notes |
|---|---|---|
[prop]="x" (read-only) | input() | Simple — tool handles well |
[(prop)]="x" (two-way) | model() | Need immutable updates |
(propChange)="fn($event)" | output() | Simple — tool handles well |
[(ngModel)]="prop.field" on input's nested property | Manual | Tool CANNOT handle — see patterns below |
Step 2: Check for Input Mutation
# Find components that mutate their @Input objects
grep -rn "this\.\(data\|item\|filter\|config\)\." --include="*.ts" src/app/ | \
grep -E "\.(push|splice|sort|reverse|[a-zA-Z]+\s*=)" | head -30
Components that mutate inputs need special handling:
- linkedSignal — For form fields that derive from parent input
- Alias + local copy — For
[(ngModel)]on nested properties - NEVER use
model()for objects with[(ngModel)]on properties — the signal function breaks ngModel binding
Step 3: Identify Complex Components (RULE 2)
Components with 10+ inputs, ngOnChanges, ViewChild interactions, or timer side effects must be migrated atomically in one pass. The grid component took 5 incremental fix commits because it was done piecemeal.
Tool-First Strategy
Run the Bulk Migration Tool
ng generate @angular/core:signals
This handles ~70% of simple conversions. Then manually fix edge cases.
What the Tool Handles Well
- Simple
@Input()→input() - Simple
@Output() EventEmitter→output() - Adding
()to template reads
What the Tool CANNOT Handle
- Input-mutating components (RULE 1)
- Complex
ngOnChangeswith conditional logic [(ngModel)]on signal properties- Two-way binding with immutable update patterns
watchedInputs()pattern for cascading dropdowns- Components with timer/subscription side effects in setters
The 5 Migration Patterns
Pattern 1: Simple input() (Most Common)
// Before
@Input() disabled = false;
// After
disabled = input(false);
// Template: disabled → disabled()
Pattern 2: Alias + Local Copy (Input Mutation with ngModel)
When the parent passes data via [prop] and the component uses [(ngModel)]="prop.field":
// Before
@Input() data: MyDTO;
// After — signal input with alias, local mutable copy
dataInput = input<MyDTO>(undefined, { alias: 'data' });
data: MyDTO;
constructor() {
effect(() => {
const d = this.dataInput();
if (d) {
this.data = { ...d }; // or Object.assign(this.data, d)
}
});
}
// Template: NO changes needed — still uses local `data` property
Pattern 3: model() (Two-Way Binding)
When the parent uses [(prop)]:
// Before
@Input() page: Page = { page: 1, perPage: 10, totalCount: 0 };
@Output() pageChange = new EventEmitter<Page>();
// After
page = model<Page>({ page: 1, perPage: 10, totalCount: 0 });
// CRITICAL: Immutable updates only!
// WRONG: this.page().totalCount = 10;
// RIGHT: this.page.set({ ...this.page(), totalCount: 10 });
Pattern 4: linkedSignal (Derived Mutable State)
When input data is used in forms but the local state can diverge from the parent:
// Before
@Input() item: TrainingDTO;
// Template: [(ngModel)]="item.trainingTime"
// After
itemInput = input<TrainingDTO>();
item = linkedSignal(() => this.itemInput() ? { ...this.itemInput()! } : undefined);
// Template: [(ngModel)]="item()!.trainingTime"
// NOTE: Need to unwrap with () and non-null assertion
Pattern 5: watchedInputs() (Cascading Dropdowns)
For dropdown components that reload options when a parent input changes:
// Before (BaseDropdown child)
@Input() province: ProvinceDTO;
inputChanges = ['province'];
// ngOnChanges checks inputChanges array
// After
province = input<ProvinceDTO>();
protected override watchedInputs(): InputSignal<any>[] {
return [this.province];
}
// Base class effect() watches these signals automatically
ngModel Split Pattern
When [(ngModel)] is bound to a signal property:
<!-- Before -->
<input [(ngModel)]="value">
<!-- After — split into one-way read + change handler -->
<input [ngModel]="value()" (ngModelChange)="value.set($event)">
For nested properties on a model():
<!-- Before -->
<input [(ngModel)]="filter[column.prop]">
<!-- After — helper method for immutable update -->
<input [ngModel]="filter()[column.prop]"
(ngModelChange)="onFilterPropChange(column.prop, $event)">
onFilterPropChange(prop: string, value: any) {
this.filter.update(f => ({ ...f, [prop]: value }));
}
Parent-Child Data Flow Rules (RULE 10)
model() Requires Two-Way Binding
When a child component uses model() with patchData-style updates, the parent MUST use [(data)]:
<!-- WRONG — child's data.set() never propagates back -->
<v-tax-form [data]="data"></v-tax-form>
<!-- RIGHT — two-way binding propagates changes -->
<v-tax-form [(data)]="data"></v-tax-form>
linkedSignal Must Mutate Source
When a child uses linkedSignal() with patchData, mutate the source input before triggering the signal:
// WRONG — disconnects from parent after first call
patchData(field: string, value: any) {
this.data.set({ ...this.data(), [field]: value });
}
// RIGHT — mutate source, then trigger CD
patchData(field: string, value: any) {
this.setData()[field] = value; // mutate parent's object
this.data.set({ ...this.setData() }); // new ref triggers CD
}
See references/signal-anti-patterns.md — Anti-Patterns 9 and 10 for real failure stories.
Object Identity Rule
Signals detect changes by object identity, NOT mutations.
// WRONG — mutation, signal won't fire
this.page().totalCount = 10;
this.activeImage().rotate = newRotate;
// RIGHT — new object reference
this.page.set({ ...this.page(), totalCount: 10 });
this.activeImage.set({ ...this.activeImage(), rotate: newRotate });
Post-Migration Verification (RULE 7)
After ANY signal migration, run these checks:
Check 1: Missing () in Templates
# Find signal properties used without () in templates
# Look for properties that are now signals but templates still reference without ()
grep -rn "\[.*\]=\"\(disabled\|loading\|viewMode\|isApproval\|canEdit\|isContractor\|selected\|filter\)\"" \
--include="*.html" src/ projects/ | grep -v "()"
Check 2: Missing () in TypeScript
# Signal properties used as truthy checks without ()
grep -rn "if (this\.\(isContractor\|canEdit\|disabled\|loading\|viewMode\))" \
--include="*.ts" src/ projects/
Check 3: Missing () in Tests
# Test files accessing signal properties without ()
grep -rn "component\.\(disabled\|loading\|page\|sort\|filter\)" \
--include="*.spec.ts" src/ projects/ | grep -v "()"
Why this matters: A signal function is always truthy. if (this.isContractor) compiles but ALWAYS evaluates to true. This was missed in 2 separate commits.
Template: Signal Unwrap Pattern
<!-- For nullable signal inputs, unwrap once with @if...as -->
@if (data(); as data) {
<input [ngModel]="data.name">
<span>{{ data.status }}</span>
}
Computed Signals for Derived State
Replace getter properties with computed():
// Before
get isThai(): boolean {
return !this.data?.nationality || this.data.nationality?.key === NationalityKey.Thai;
}
// After
isThai = computed(() => {
const d = this.data();
return !d?.nationality || d.nationality?.key === NationalityKey.Thai;
});
ViewChild Migration Summary
| Decorator Pattern | Signal Equivalent |
|---|---|
@ViewChild('ref') (non-static) | viewChild<T>('ref') |
@ViewChild('ref', { static: true }) | viewChild.required<T>('ref') |
@ViewChildren(...) | viewChildren<T>(...) |
References
references/signal-patterns.md— Complete pattern catalog with examplesreferences/signal-anti-patterns.md— What NOT to do (with real failure stories)references/input-mutation-catalog.md— How to identify and handle input-mutating componentsreferences/softever-base-classes.md— Production-migrated base classes (BaseDropdown with watchedInputs, BaseRadio, BaseInput, BaseFeatureGridComponent legacy pattern, BaseDocumentCheck ViewChild pattern, E-Form base classes)references/softever-lib-components.md— Production-migrated library components (GridComponent with model/computed/effect, TabComponent, PaginationComponent, FileUploadButton with linkedSignal, LibSkeletonDirective)