Lessons Learned from Creating A Typeahead With RxJs And Angular (2+)

Following recent articles on development of Echoes Player, my open source media player built with angular (v2), I really wanted to implement a typeahead feature for this version. There are some great ng2-typeahead out-there (available in npm), however, I wanted to take this opportunity to built something from scratch - exploring deeper concepts in angular 2. I learned quite a few from implementing a typeahead component and i'm sharing these in this article.

Typeahead - Design

I had a clear vision of how the typeahead feature should work:

  1. it should be used as an attribute to an input element
  2. it should be rendered as a sibling to the input element
  3. it should expose a selected result event
  4. it should support keyboard navigation in suggestions list
  5. it should support custom template for suggestions list
  6. it should cancel suggestions when "Escape" key is pressed
  7. it should cancel suggestions when clicking outside the suggestions container

I wrote on my experience with RxJs and angular 2 before, and this article takes this approach further.

Typeahead - Implementation

I decided to use the "@Component()" decorator, as it allows to have template - which I wanted to have for this implementation.

1. typeahead as an attribute

defining a component as an attribute is simple since the @Component's "selector" values are parsed as css selectors:

2. typeahead suggestions as a sibling to the element

In order to achieve this, I had to define this component with the @Component() decorator. However, since this component is added to an input element, If I use regular template that will be rendered with Angular's engine, than it will be rendered inside the input element. This will show up in the devtools like this:

I took a slightly different approach with this challenge. I remembered seeing few videos on youtube (Rob Warmwald) explaining about the strengths of the template engine within Angular and how it can be used to achieve complex ideas. At first, the template for the typeahead was quite simple. The template supports the following:

  1. declare a name for the template
  2. toggle the template contents with "ngIf" boolean statement
  3. rendering a simple array of suggestions (referred as results)
  4. marking the currently active result with an "active" css class
  5. handle a click event for selecting a result

Lets define the class which handles this template.

3 - expose a selected event

In order to support the typeahead selected event, I defined an event of string with the @Output() decorator. The rest of the following properties, match the variables that are referenced from the template aside from subscriptions and activeResult.

"subscriptions" is an array of subscriptions which this component use to store rxjs subscriptions which should be disposed once this component is destroyed (we'll get to these later).

The constructor injects these:

  1. element - to listen for input events
  2. viewContainer - in order to render the template as a sibling (bullet #2)
  3. jsonp - start requests as a jsonp to google's search api
  4. cdr - change detection reference to apply changes within this component and its siblings

Component Setup

First, this component sets up the proper subscriptions when it is ready in the ngOnInit lifecycle:

Before we dive into each function, I want to explain the "renderTemplate()" function.

With this function, i'm using the "viewContainer" in order to render the template as a sibling to the actual element. The "createEmbeddedView" function takes a template reference and inserts it, compiled with the data, into the last view position in the html container. The actual "viewContainer" is the element that wraps the input element (in this case). A second argument determines  the index at which this template should be rendered.

cdr - ChangeDetectorRef for change detection

The use of "cdr" turned out to be useful for detecting changes and telling angular to render this component again. Since i'm using a parent component (PlayerSearch Component) with a change detection strategy set to "OnPush" - using "markForCheck()" is a way of telling angular that there are changes within this component which are not originated from the "Input()"s, so it needs to re-render the component.

4 - Keyboard Support

lets go over the actual rxjs code which creates the typeahead behavior for this component.

In order to support selection of a result with the enter key, this code listens to keydown strokes on the input element, allows only Enter key to pass on to the stream and then invokes the "handleSelectSuggestion" which eventually - emits an event for the selected result.

The actual logics which makes a jsonp call to get the list of suggestions according to the value of the input, is implemented in the "listenAndSuggest()" function.

This time, the code listens to keyup strokes (since we do want to allow a character to apply into the input), filtering out any non characters keys with "validateKeyCode()". Here I set a debounce of 400ms as a reasonable time for not doing too many request in a given time. I use "distinctUntilChanged()" to filter the stream for the same value - which again prevents unnecessary requests. Then, the filter for an empty string becomes relevant before the code makes the actual request.

Now, i'm using "switchMap" since i'm expecting to pass a new observable which should be returned from the "suggest(query)" function.

The subscribe starts the execution of this stream, saves the results and sets the suggestions box to show the relevant view. Since this code is not connected to angular, I have to use the "markForCheck()" to instruct angular to re-render the component and its parent, regardless of the change detection strategy that is used. An important note in this context is that in order to achieve this chain of operations and decisions, without RxJs, I would have written a much more complex code, probably one that would not fit a single short function like this one.

NOTE: Currently, this component is not reusable anywhere else, so I hardcoded the url and the params of the jsonp request to the "suggest()" function. However, if I had to make this component reusable, I would have have few ways to take out hard coded "suggest()" function:

  1. Pass "suggest" as an @Input() which should get an observable.
  2. Pass "url" and "searchParams" as @Inputs - this would restrict the typeahead component to jsonp requests only.

Achieving one of the two is pretty straight forward - you can take this a good exercise. Here is the suggest code:

accessible navigation with arrows

To achieve this feature, the code listens to the 'keydown' key strokes allowing only arrow keys to pass. The "subscribe" method starts the execution of this stream and applies logics for marking the relevant suggestion as active.

5 - allow custom template for suggestion

To achieve this challenge, I had to use more angular template engine feature - which explains how some of the features of this engine work behind the scenes.

First, I added a new Input which gets a template reference:

Then I added a template inside the button element to allow rendering this template ref when present. In order to render a template reference, the "ngTemplateOutlet" directive is used with the template tag. In order to add a context for the external template, so the result value can be referenced, Angular supplies the "ngOutletContext" directive. This directive should receive a literal object with the special "$implicit" property. This property is used as the contect for the contents of the template tag (the one in this button) - then, the "result" and "index" variables can be used as expressions within the template that is passed through from outside.

A good example for defining a template and passing it as a reference is described in this snippet:

6 - "@HostListener" cancel suggestions box with Escape key

For achieving this feature, I chose to use the "@HostListener()" decorator - a simple function for filtering the Escape key (not rxjs for this one). From this snippet we can learn that the event object can be passed as an argument to any function as well as other members or properties of this component's context (i.e, 'results' can be sent as a second argument).

7 - cancel suggestions when clicking outside

The suggestions box is similar to a dropdown. To allow this feature, I took the approach of having a transparent background - "typeahead-backdrop" div below the box, combined with a simple click event which will hide (cancel) the suggestions box - a reuse to this function. This is simply done with adding a div element to the template along with the relevant click event:

All Lessons Learned From Typeahead Component

You can see the end result live on Echoes Player App and in this screenshot:

To summarize the great benefits came out form this experience, we learned these:

  1. use changeDetectionRef to force a re render inside an "OnPush" defined component
  2. Angular's template engine can be reused to achieve greater power in creating complex components
    1. reuse templates with ngTemplateOutlet directive
    2. apply different context to template with ngOutletContext directive
  3. @HostListener() can pass more arguments other than the event object
  4. RxJS can reduce the amount of code dramatically and make it maintainable as well

There are few optimizations that I can make to this code and other ways to achieve it. However, this is a great way to experiment with other concepts, open your (or mine) mind and dig more into the source, understanding how it runs and how features might serve us in achieving various tasks and components features.

The full source code is available at github.

For more articles about angular 2, please check out the angular article series page.

Contact to get a special offer for angular 2 consulting

  • Sean

    very nice!

  • awesome to see the thinking of how you could do different things to make it reusable and the pros and cons. Thanks for sharing

  • Thanks Duncan

  • Miguel García López

    Woow, this was such a detailed and informative post! I have learnt a lot, congratulations for the neat explanations and the reasoning you followed.

  • thank you for your feedback @miguel_garc_a_l_pez:disqus