ARTICLE AD BOX
Let's take this simple example: I have want to write an app that edits an objects that has a list of e.g. users as one property and a user edit component should be rendered for every user in the list.
Example 1: Very simple user edit component
This simple case works as expected. Interesting to see that angular adds a Symbol property to help with tracking (StackBlitz Example 1)
interface User { name: string; } interface Test { test: string; users: User[]; } @Component({ selector: 'user-edit', template: `<div>Name: <input type="text" [formField]="valueForm.name"/></div>`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormField], }) export class UserEditComponent implements FormValueControl<User> { public readonly value = model<User>({ name: 'dummy' }); protected readonly valueForm = form(this.value); } @Component({ selector: 'app-root', template: ` @for(userForm of valueForm.users; track $index) { <p>{{ userForm().value() | json }} {{listSymbolKeyValues(userForm().value()) }}</p> <user-edit [formField]="userForm" /> } <button (click)="addUser()">Add User</button> `, imports: [UserEditComponent, FormField, JsonPipe], }) export class App { public readonly value = signal<Test>({ test: 'a', users: [{ name: 'a' }, { name: 'b' }], }); public readonly valueForm = form(this.value); addUser() { this.valueForm .users() .value.update((list) => [...list, { name: `user ${list.length}` }]); } listSymbolKeyValues(o: any): string {/*...*/} }Example 2: Separate domain and form model for user edit component
Now we modify the user edit component: Let's assume that the user is a a wee bit more complicated model that requires to have a adjusted edit form model that differs from the domain model (see angular docs). To keep our example simple here, we just say that the user's name is optional and an empty name should be saved as undefined rather than an empty string.
So here we introduce a UserForm model and use a mappedForm helper for the edit component to help translating the domain model to the form model and back using a linkedSignal and an effect internally (see this github issue and StackBlitz Example 2 for complete source code).
interface User { name?: string; } interface UserForm { name: string; } interface Test { test: string; users: User[]; } @Component({ selector: 'user-edit', template: `<div>Name: <input type="text" [formField]="valueForm.name"/></div>`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormField], }) export class UserEditComponent implements FormValueControl<User | null> { public readonly value = model<User | null>(null); protected readonly valueForm = mappedForm<User | null, UserForm>(this.value, { modelToForm: (user) => { user ??= createUser('new user'); return { name: user.name ?? '(unnamed))' }; }, formToModel: (userForm) => ({ name: userForm.name || undefined, }), }); }What we notice is that the tracking Symbols added by Angular that were stable in example 1 above, now are newly created for each edit that happens, e.g. every key typed into the name edit input. While this works, I'd fear that this could have a major performance impact when we use more heavy components.
Example 3: Adjust the loop tracking to use a user ID
So now let's modify our example once again: I tried to add a id to each user and use this for tracking in the loop like this. (StackBlitz Example 3)
interface User { id: number; name?: string; } interface UserForm { id: number; name: string; } interface Test { test: string; users: User[]; } let userCounter = 0; function createUser(name?: string): User { return { id: ++userCounter, name }; } @Component({ selector: 'user-edit', template: `<div>Name: <input type="text" [formField]="valueForm.name"/></div>`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormField], }) export class UserEditComponent implements FormValueControl<User | null> { public readonly value = model<User | null>(null); protected readonly valueForm = mappedForm<User | null, UserForm>(this.value, { modelToForm: (user) => { user ??= createUser('new user'); return { id: user.id, name: user.name ?? '(unnamed))' }; }, formToModel: (userForm) => ({ id: userForm.id,name: userForm.name || undefined, }), }); } @Component({ selector: 'app-root', template: ` <!-- instead of $index use form's user ID for tracking --> @for(userForm of valueForm.users; track userForm.id().value()) { <p>{{ userForm().value() | json }} {{listSymbolKeyValues(userForm().value()) }}</p> <user-edit [formField]="userForm" /> } <button (click)="addUser()">Add User</button> `, imports: [UserEditComponent, FormField, JsonPipe], }) export class App { public readonly value = signal<Test>({ test: 'a', users: [createUser('a'), createUser('b')], }); public readonly valueForm = form(this.value); addUser() { this.valueForm .users() .value.update((list) => [...list, createUser(`user ${list.length}`)]); } }I expected that this improved tracking, but instead I get a runtime error and adding or editing users does not work.
ERROR RuntimeError: NG01904: Orphan field, can't find element in array <root>.users at Object.computation (_structure-chunk.mjs:953:15) at Object.producerRecomputeValue (_effect-chunk.mjs:310:25) at producerUpdateValueVersion (_effect-chunk.mjs:102:8) at computed2 (_effect-chunk.mjs:271:5) at Object.computation (_structure-chunk.mjs:850:40) at Object.producerRecomputeValue (_effect-chunk.mjs:310:25) at producerUpdateValueVersion (_effect-chunk.mjs:102:8) at consumerPollProducersForChange (_effect-chunk.mjs:170:5) at producerUpdateValueVersion (_effect-chunk.mjs:98:45) at linkedSignalGetter (_linked_signal-chunk.mjs:17:5)So, what is the best approach for this kind of cases?
