Angular2 external subscriber in a directive (Output)

You made a great component that leverage reactive forms. The thing is, you want to share this directive, and let users define the Observable that should be used by the custom form component. The naive way to make this work would be to bind that Observable in an @Input, and then use that input in the component. We will see here why it doesn’t feel right and how to implement it as an @Output.

Basic reactive component

Let’s start with an example Component:

export interface ListItem {
  name: string;
}

// Some data to work with
const list: ListItem[] = [
  {name: 'foo'},
  {name: 'bar'},
  {name: 'bar2'},
  {name: '2bar'},
  {name: 'test'}
]

// The reactive input component
@Component({
  selector: 'my-reactive-input',
  template: `
    <input [formControl]="searchControl">
    <ul>
      <li *ngFor="let item of items">
        {{item.name}}
      </li>
    </ul>
  `,
})
export class MyReactiveInput implements OnInit {
  searchControl: FormControl = new FormControl();
  @Input() items: ListItem[]

  ngOnInit() {
    // the reactive magic
    this.searchControl.valueChanges
    .debounceTime(500)
    .distinctUntilChanged()
    .switchMap((term: string) => {
      this.items = []
      // When a change occurs, we call this update function
      return this.update(term)
    })
    // which gives us new items to show
    .subscribe((item: ListItem) => {
      this.items.push(item)
    })
  }

  // basic search function on ListItem[]
  update(term) {
    let items = list.filter((e: ListItem) => {
      return new RegExp(term, 'gi').test(e.name)
    })

    return Observable.from(items)
  }
}

There is the full code in a plunkr:

This directive can be used like this:

<my-reactive-input [items]="items"></my-reactive-input>

We can add initial items through the [items] binding. The search function though, is not customizable.

Let’s try to add a binding for it so that we can reuse this directive!

Adding the user search function through @Input

First, let’s try by using the naive solution ; we want a user function in our directive, it’s an @Input.

The markup would be like this:

<my-reactive-input [items]="items" [search]="search"></my-reactive-input>

On the updated component, I intentionally removed the typing on the list. As it’s a user input we don’t know how the data looks like.

export class MyReactiveInput implements OnInit {
  searchControl: FormControl = new FormControl();
  @Input() items: any[]

  // search is now an input
  @Input() search: () => void

  ngOnInit() {
    this.searchControl.valueChanges
    .debounceTime(500)
    .distinctUntilChanged()
    .switchMap((term: string) => {
      this.items = []
      return this.search(term)
    })
    .subscribe((item: any) => {
      this.items.push(item)
    })
  }
}

The component now binds it’s own function through search:

@Component({
  selector: 'my-app',
  template: `
    <my-reactive-input [items]="items" [search]="update"></my-reactive-input>
  `,
})
export class App {
  myList: ListItem[] = list

  update(term): Observable<ListItem> {
    let items = this.myList.filter((e: ListItem) => {
      return new RegExp(term, 'gi').test(e.name)
    })

    return Observable.from(items)
  }
}

What could go wrong?

Be my guest and type something in the box with your javascript console open in this plunkr. You should get this error:

TypeError: Cannot read property ‘filter’ of undefined

Whoops! Indeed, when we call the search method in the MyReactiveInput, this refers to the MyReactiveInput instance and not the App instance. Therefore, this.myList refers to MyReactiveInput.myList which is obviously undefined.

We could fix this by adding a [context] input, refering to the current instance and MyReactiveInput would call this.search.call(this.context, term).

Don’t, it’s wrong.

Instead, let’s do the the same by using an @Output binding.

Using @Output to call the user function in the correct context

Let’s review the markup:

<my-reactive-input [items]="items" (onUpdate)="search($event)"></my-reactive-input>

It does feel more natural to me, onUpdate let’s call search.

Let’s review the component. As we’re using a @Output, which is an EventEmitter, we will create an interface for this new event. It will contain the search term and an Observer:

export interface MyReactiveInputEvent {
  term: string;
  observer: Observer<any>;
}

We can then add our @Output, and slightly change the way we call the binded output:

  //...
  @Output() onUpdate: EventEmitter<MyReactiveInputEvent> = new EventEmitter()

  ngOnInit() {
    this.searchControl.valueChanges
    .debounceTime(500)
    .distinctUntilChanged()
    .switchMap((term: string) => {
      this.items = []

      // Some magic, we create an Observable
      return Observable.create((observer: Observer<any>) => {
        // And we emit the observer to our user-defined function
        this.onUpdate.emit({term: term, observer: observer})
      })
    })
    .subscribe((item: any) => {
      this.items.push(item)
    })
  }
}

The App component needs some changes to reflect this behavior:

// The template now uses an Output binding
@Component({
  selector: 'my-app',
  template: `
    <my-reactive-input [items]="myList" (onUpdate)="search($event)"></my-reactive-input>
  `,
})
export class App {
  myList: ListItem[] = list

  search(event: MyReactiveInputEvent) {
    let items = this.myList.filter((e: ListItem) => {
      return new RegExp(event.term, 'gi').test(e.name)
    })

    // Just create a stream
    Observable.from(items)
    // And subscribe with the event Observer to transfer back the data to the Directive
    .subscribe(event.observer)
  }
}

Note that if you want your @Output to be optional, you can add this check to see if some observers are registered:

// This allows the call to continue even if the `onUpdate` output is not defined
if (this.onUpdate.observers.length === 0) {
  return Observable.empty()
}

Here is a demo:

Of course, this can now work with asynchronous requests instead of using Observable.from! For example, by using this in combination with rxrest!

In the demo I hard-coded {{item.name}}, this is obviously not ideal and it will be covered in another article soon!