top of page
Search
  • Writer's pictureZsolt

Object inputs are still dangerous, even with Angular signal



Back in the day (in 2023) when you passed down an object to an @Input you had to be careful inside the component how you used it. Somewhat obviously mutating an @Input inside the child component is a bad practice as it is outside of the expected communication channel between the parent and child, and if it goes wrong (and it will) all the harder to debug.

Sadly it was quite easy to fall for this practice without extra caution and looking out for it. Now, input signals are read-only, so you can no longer mutate the input object, so the problem solved? Mostly, but with a little carelessness it can still go wrong and in a more major way, let me show.

The “Old way”:

@Component({
  ...
  template: `
    <h1>Input version</h1>
    <app-old-input-way [objectInput]="parentObject"></app-old-input-way>

    <hr class="solid">

    <p> Parent object </p>
    {{ parentObject | json }}
  `,
})
export class App {
  parentObject: ObjectInput = {
    name: 'parent',
    value: 0,
    props: { id: 'id1' },
  };
}

@Component({
  ...
  template: `
      {{ extendedObject | json }}
      <button (click)='onSomeAction()'>Action!</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OldInputWayComponent {
  private _objectInput: ObjectInput | undefined;
  @Input()
  set objectInput(objectInput: ObjectInput) {
    this._objectInput = objectInput;
    this.extendedObject = { ...objectInput };
    this.extendedObject.props.enabled = true;
  }

  get objectInput(): ObjectInput | undefined {
    return this._objectInput;
  }

  extendedObject: ExtendedObjectInput | undefined;

  onSomeAction() {
    this._objectInput!.value++;
  }
}

The setup is simple: we have a parent component with an object that’s the input for the child component. The child component itself needs a decorated version of that same object, but only for its inner workings (for some reason, the most common reason being legacy 😆). In this case, the only difference is enabled on props . This whole thing is not a great design, but the point is to show how with a little carelessness how bad the situation can get, fast. enabled appears on the parent, and value change only affects the parent.


One of the correct ways here (if you really need an extended version of the same object) would be to deep copy the input object at the assignment to the private one. When the private one changes also call an @Output to notify the parent about the changes. Don’t forget to avoid circular setting with an equality check.

export class OldInputWayComponent {
  private _objectInput: ObjectInput | undefined;
  @Input()
  set objectInput(objectInput: ObjectInput) {
    if(isEqual(objectInput, this._objectInput) {
      return;
    }
    this._objectInput = cloneDeep(objectInput);
    this.extendedObject = { ...this._objectInput };
    this.extendedObject.props.enabled = true;
  }

  get objectInput(): ObjectInput | undefined {
    return this._objectInput;
  }

  @Output() objectChange = new EventEmitter<ObjectInput>()

  extendedObject: ExtendedObjectInput | undefined;

  onSomeAction() {
    this.extendedObject.value++;
    const newObj = { ...this.extendedObject, props: { id: this.extendedObject.id } };
    this.objectChange(newObj);
  }
}

This is a very unrefined implementation, a blueprint of the train of thought, does not compile, do not try to copy it as-is. If you want a good design to copy, go check the ngModel’s implementation with model and viewModel being separated.


 

With Signals:

Signal input is read-only, how could we possibly mess that up? Hold my latte avocado:

export class SignalInputWayComponent {
  objectInput = input.required<ObjectInput>(); // <-- the signal input

  extendedObject = computed<ExtendedObjectInput>(() => {
    const base: ExtendedObjectInput = this.objectInput();
    base.props.enabled = this.enabled();
    return base;
  });

  private enabled = signal<boolean>(true); // <-- the extension

  onSomeAction() {
    const obj = this.objectInput();
    obj.value++;
  }
}

At first glance it’s visible how it requires some effort to mess it up, but luckily not that much. What we did in the original was to just create an extended object from the original and use that however we want in the child component, getting-setting its properties, which does not work here. We need a computed signal to update the extended object, and computed signals can’t be updated directly either, so we need a signal for the extension part. Normally we’d just use the extension signal(s) to set the extendedObject and pass on extendedObject to whoever needs to read it.


The problem: through base , extendedObject holds reference to the parent object again. And then the cherry on top, onSomeAction , let’s see what it does:

We’ve added the extension to the parent object and also modified both signals’ values (which are, again, read-only).

Just for anarchy’s sake, let’s combine the 2 wrong solutions (and to see what I meant that things can go wrong fast).


Debug that

export class SignalInputWayComponentFixed {
  objectInput = input.required<ObjectInput>();
  change = output<ObjectInput>();

  extendedObject = computed<ExtendedObjectInput>(() => {
    const base: ExtendedObjectInput = _.cloneDeep(this.objectInput()); // this can be costly, so I don't recommend for every use-case
    return {
      ...base, // base obj > extended obj, except for the extension parts
      props: _.merge(this._extendedObject().props, base.props), 
    };
  });

  private _extendedObject = signal<ExtendedObjectInput>({
    name: '',
    value: 0,
    props: { id: '', enabled: false },
  });

  onSomeAction() {
    this._extendedObject.update((obj) => {
      obj.props.enabled = !obj.props.enabled;
      obj.value++;
      return obj;
    });
    const newExtendedObj = this._extendedObject();
    this.change.emit({
      ...this.objectInput(),
      value: newExtendedObj.value,
    });
  }
}

Summary

Don’t do this kind of object extension if possible. If not possible separate the inner and outer states as much as possible and always give precedence to the outer state that comes as an input for the non-extension parts.

Look how beautifully it works then 🙂


If you liked this post, give it a clap on the original post https://itnext.io/object-inputs-are-still-dangerous-even-with-angular-signals-9103a25d5e45

The original one also looks way better 🤩

0 views0 comments

Comments


bottom of page