8

Current Situation:

I have a parent and a child component.

The parent initializes the child's data using its @Input. And the child notifies the parent, when the user edited the data using the @Output. And because the data is immutable, the child has to send the data along with that notification.

When the parent got notified, it will check if the submitted data is valid, and then will set it (this will propagate the new value to some other child components as well).

The Problem:

When setting the new data inside the parent, it will of course also give it to the child component which just submitted the data. This will trigger the child's ngOnChanges, which then triggers a repaint of the UI.

Some Background:

The parent has several different child components, which all rely on the same myItem data and can edit this data and then notify the parent on change.


Here's a simplified version of the code, that should show up the problem.

Parent Component:

template:
    <child [input]="myItem" (output)="onMyItemChange($event)">

code:
    ngOnInit() {
        this.myItem = getDataViaHTTP();
    }

    onMyItemChange($event) {
        if($event.myItem.isValid()) {
            this.myItem = $event.myItem;
        }
    }

Child Component:

template:
    <input [(ngModel)]="myItem.name" (ngModelChange)="modelChange($event)">

code:
    @Input() input;
    @Output() output = new EventEmitter();

    myItem;

    ngOnChanges(changes) {
        this.myItem = changes.input.currentValue.toMutableJS();
    }

    modelChange($event) {
        this.output.emit(this.myItem.toImmutableJS())
    }

As you can see, the child component takes the data from the @Input and makes it mutable. And before sending it back to the parent it will make it immutable again.


Is there any pattern to prevent these circular events?

Benjamin M
  • 23,599
  • 32
  • 121
  • 201

2 Answers2

6

I can't think of a way to break the away from the circle if we stick with bi-directionaly event trigger. Especially with multiple children.

Method 1

One way I can think of is both parent and children use a share data service. Data is change once and for all, as all parties are using the same data.

globaldata.service.ts

import { Injectable } from '@angular/core';

interface ShareObj {
  [id: string]: any;
}

@Injectable()
export class GlobalDataService {
  shareObj: ShareObj = {};
}

app.module.ts(assume this is your root module)

import { GlobalDataService } from './globaldata.service';
//
// skip ..
//

@NgModule({
  //
  // skip ..
  //

  provider:[GlobalDataService]

})
export class AppModule {}

parent.component.ts (assuming non-root, multiple instances, part of app.module)

template:
    <child [parent]="myId"></child>

code:
    import { GlobalDataService } from './globaldata.service';
    //
    // skip ..
    //

    // use uuid to generate unique id
    private uuid = require('node-uuid');
    myId = this.uuid.v1();

    constructor(private gd: GlobalDataService){
        // This can be string, array or object
        this.gd.shareObj[myId]='data';
    }

child.component.ts

template:
    <input [(ngModel)]="gd.shareObj[parent]">

code:
    import { GlobalDataService } from './globaldata.service';
    //
    // skip ..
    //

    constructor(private gd: GlobalDataService){}

    @Input() parent;

Method 2 - broadcast queue

Use RxJs subject subscription, like a broadcast queue. I have actually created a package with example:

https://github.com/J-Siu/ng2-simple-mq

https://github.com/J-Siu/ng2-simple-mq-example

The idea:

  1. Parent and all children will subscribe to the same queue
  2. Include a sender id when broadcasting to the queue, you can use the subscription id as sender id, if you use my package, as it is an uuid.
  3. Callback will check the sender id and don't take any action if message is from self

Parent (assuming non-root, multiple instances, part of app.module)

import {Component, OnInit} from '@angular/core';
import {SimpleMQ} from 'ng2-simple-mq';

template:
    <child [parent]="myId"></child>

code:
  export class SomeComponent implements OnInit {
    title = 'Some Component';

    // use uuid to generate unique id
    private uuid = require('node-uuid');
    myId = this.uuid.v1();
    myItem = {};

    constructor(private smq: SimpleMQ) { }

    ngOnInit() {
        this.smq.subscribe(this.myId, e => this.receiveBroadcast(e));
    }

    broadcast() {

        let msg = {
            id: this.myId,
            msg: 'some messages or object go here'
        };

        // Publish to queue name 'this.myId'
        this.smq.publish(this.myId, msg);
    }

    receiveBroadcast(m) {
        if (m.id !== this.myId) {
            // msg from soneone else, lets do something

            this.myItem = m.msg; // Update local data

            console.log(m.Id + ' received: ' + m.msg);
        }
    }
}

Child

import {Component, Input, OnInit} from '@angular/core';
import {SimpleMQ} from 'ng2-simple-mq';

template:
<input [(ngModel)]="myItem.name" (ngModelChange)="broadcast()">

code:
  export class SomeComponent implements OnInit {
    title = 'Some Component';

    @Input() parent;
    // use uuid to generate unique id
    private uuid = require('node-uuid');
    myId = this.uuid.v1();

    myItem = {};

    constructor(private smq: SimpleMQ) { }

    ngOnInit() {
        this.smq.subscribe(parent, e => this.receiveBroadcast(e));
    }

    broadcast() {

        let msg = {
            id: this.myId,
            msg: this.myItem // send the whole object
        };

        // Publish to queue name = parent id
        this.smq.publish(parent, msg);
    }

    receiveBroadcast(m) {
        if (m.id !== this.myId) {
            // msg from soneone else, lets do something

            this.myItem = m.msg; // Update local data

            console.log(m.Id + ' received: ' + m.msg);
        }
    }
}
John Siu
  • 5,056
  • 2
  • 26
  • 47
  • The **1st approach** has some limitations: The service must know how many children there are. The **2nd approach** looks somewhat nicer to me. I think it should also be possible to throw away `SimpleMQ` and just create a `Rx.Subject` within the parent and pass it to each child via `@Input`. When using a global service you may face another problem if you use the parent component multiple times. – Benjamin M Aug 19 '16 at 12:23
  • (1) Not quite follow on the 1st approach limitation. (2) For Angular2 RC5, `providers` is set in `module` file, there should be no problem even multiple copies of root component is created. – John Siu Aug 19 '16 at 14:15
  • Looked like you created there `myItem0` for the parent, `myItem1` for 1st child, `myItem2` for 2nd child. If I add a 3rd child to the parent I have to modify the Service. – Benjamin M Aug 19 '16 at 15:00
  • @BenjaminM Updated both methods code. For method 1, each parent/children tree will share 1 shareObj element, using the parent's `myId` as index. For method 2, each parent/children tree have 1 queue, using parent's `myId` as queue name. If you need action after the share value change, you will need Method 2. – John Siu Aug 19 '16 at 17:41
0

I now came up with a working (but very ugly solution):

  1. child stores the last emitted value within modelChange

  2. when ngOnChanges gets called, we can compare the references of those objects

  3. if references are equal, then this particular child component created the value


Simplified code example:

@Input() input;                            // most recent version (immutable)
@Output() output = new EventEmitter();

myItem;                                    // mutable version
lastMyItem;                                // last emitted version (immutable)

ngOnChanges(changes) {
    // this will only trigger, if another component
    // created the data instance

    if(changes.input.currentValue !== lastMyItem) {
        this.myItem = changes.input.currentValue.toMutableJS();
    }
}

modelChange($event) {
    this.lastMyItem = this.myItem.toImmutableJS();
    this.output.emit(this.lastMyItem);
}

It's working as expected, but I still wonder if there's a nicer way using less code.

Maybe it's possible to work around this using Observables for the @Input() input?

Benjamin M
  • 23,599
  • 32
  • 121
  • 201