DEV Community

Cover image for A tour of Angular for web developers: Composing the user interface with components
Nalaka Jayasena
Nalaka Jayasena

Posted on

A tour of Angular for web developers: Composing the user interface with components

This is the second part of our brief tour of the Angular framework. In this post, we take a closer look at the primary building block of an angular application, the component.

This series is best read in sequence, as each one usually builds upon the previous ones. The target audience is somewhat experienced web developers, specially those coming from another framework like React, Vue, or Ember. However, even if you are a beginner with a decent grasp of HTML, CSS, TypeScript, HTTP and NodeJS, you should still be able to easily follow along. and at the end of the series, have a good idea of how Angular works and how to go about using it to build modern web applications.

Table of Contents

The journey so far

In the previous post, we created a new Angular workspace and then added a web application project to it. If you haven't read through that yet, please go ahead and do.

Here's a recap of the commands we used:

# Install ng (The Angular CLI)
npm install -g @angular/cli

# Create an empty workspace
ng new into-angular \
        --strict --create-application="false"

# Create a web app project
ng generate application hello \
       --strict  --inline-template  --inline-style

cd into-angular  

# Run hello project in local dev server
ng serve hello --open             

# Run unit tests for hello project
ng test hello

# Run end-to-end tests for hello project
ng e2e hello

# Do a production build on the hello project
ng build hello --prod

# Install serve (simple web server)
npm install -g serve

# Serve the production build locally
serve into-angular/dist/hello
Enter fullscreen mode Exit fullscreen mode

Back to TOC.

The anatomy of a component

Now open the into-angular/projects/hello/src/app/app.component.ts file. It defines AppComponent, the component that sits at the top of the component hierarchy making up our apps' user interface.

The generator has populated this file with some content useful for Angular novices. After having a look at that, delete everything and replace it with:

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

@Component({
  selector: 'app-root',
  template: `
    <div>
      <h1>Hello {{ name }}!</h1>
    </div>
  `,
  styles: ['h1 { color: darkred; }']
})
export class AppComponent {
  name = 'world';
}
Enter fullscreen mode Exit fullscreen mode

This is the classical "Hello World Program"- a minimal program that displays the text "Hello World!".

If the ng serve command was already running when the above change was made, you should see the development server picking up the change to the app.component.ts file, do a build, and hot reload the browser; this time with just the text "Hello World!".

Let's take a closer look:

The anatomy of a simple Angular component

The @Component decorator "decorates" the plain class AppComponent with component metadata and identifies it as a component.

  • The selector is a CSS selector expression for locating DOM nodes where this component should be attached to.
  • The template is a string of HTML that represents the structure of the contents of this component.
  • The styles is an array of CSS strings that applies styling to the DOM elements making up this component.

Having the components' code, the HTML template, and the CSS styles in a single app.component.ts file was a nice feature. However, for more complex components, it is more convenient to move the HTML and CSS files to separate files. We can do that with this:

@Component({
  selector: 'app-root',
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
Enter fullscreen mode Exit fullscreen mode

In fact, this is the default behavior. It is what would have been generated if we had omitted the --inline-template, and --inline-style options to the ng generate application command.

Back to TOC.

Interpolation

The HTML template in above example contains a template expression enclosed in a pair of double braces.

<h1>Hello {{ name }}!</h1>
Enter fullscreen mode Exit fullscreen mode

Template expressions are side-effect free JavaScript expressions that are evaluated in the context of a component object. In this case, the context is:

name = 'world';
Enter fullscreen mode Exit fullscreen mode

Since the components' name property is initialized to the value 'world', when the component first renders, we see that the text node inside the h1 element contains the text Hello world!.

If we programmatically change change the value of the components' name property, Angular will detect the change, re-evaluate the template expression, and update the DOM.

Back to TOC.

Pipes

Try changing the template expression to:

<h1>Hello {{ name | uppercase }}!</h1>
Enter fullscreen mode Exit fullscreen mode

What do you see on the screen. Can you guess what's going on?

Try changing to:

<h1>Hello {{ name | uppercase | titlecase }}!</h1>
Enter fullscreen mode Exit fullscreen mode

What do you see on the screen?

The "|" character is used in template expressions to act as a "pipe" that pumps the left-side expression as a parameter to the function on the right. These can be chained as we see above to form a "pipe line" through which the value of the left most expression flows through.

  • The expression name starts with the value "world"
  • The expression name | uppercase is equivalent to uppercase("world"), which evaluates to WORLD
  • And name | uppercase | titlecase is equivalent to titlecase(uppercase(name) which evaluates to titlecase("WORLD") which finally evaluates to "World"

So we can use pipes to transform our template expressions. Angular comes with a bunch of built-in pipes, let's try the date pipe to format a time string:

{{ currentTime | date: 'h:mm a on MMMM d, y' }}
Enter fullscreen mode Exit fullscreen mode

Of course you need the currentTime property set in the component:

currentTime = Date()
Enter fullscreen mode Exit fullscreen mode

It is also quite easy to create our own custom pipe- it is just a simple function after all.

Back to TOC.

A new pipe

Let's create a funkycase pipe that changes "world" to "wOrLd"

ng generate pipe funkycase
Enter fullscreen mode Exit fullscreen mode

Notice that we didn't specify a project name as the hello project is used by default.

Run the unit tests with ng test - notice the new test that was created for our "funkycase pipe"

Try applying our new pipe:

<h1>Hello {{ name | funkycase }}!</h1>
Enter fullscreen mode Exit fullscreen mode

We only get Hello !

The template expression has produced and empty result- i.e. funkycase("world") hasn't returned a valid value. Let's open the generated into-angular/projects/hello/src/app/funkycase.pipe.ts and fix this:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'funkycase',
})
export class FunkycasePipe implements PipeTransform {
  transform(value: unknown, ...args: unknown[]): unknown {
    if (typeof value === 'string') {
      return [...value]
        .map((character, index) =>
          index % 2 === 0
            ? String(character).toLowerCase()
            : String(character).toUpperCase()
        )
        .join('');
    }

    return value;
  }
}
Enter fullscreen mode Exit fullscreen mode
  • [Line 3-5] The @Pipe decorator marks our FunkycasePipe class as a pipe
  • [Line 6-7] Our class implements transform() from the PipeTransform interface.
  • [Line 9] First we use the ... syntax to spread out the string valued input into an array of strings
  • [Line 10-13] Then we map over this array collecting the "lowercase version if the index is even" and the "uppercase version otherwise" to a new array
  • [Line 15] Finally we join this new array to form the end result
  • [Line 18] If the input value is not a string just return it back

Let's add some more unit tests for this in app.component.spec.ts

it('handles a string', () => {
  const pipe = new FunkycasePipe();
  expect(pipe.transform('apple')).toEqual('aPpLe');
});

it('handles a number by passing it through unchanged', () => {
  const pipe = new FunkycasePipe();
  expect(pipe.transform(42)).toEqual(42);
});
Enter fullscreen mode Exit fullscreen mode

Run ng test to see if all is still well.

You may get an error regarding funkycase not found from the AppComponnt unit tests. This is because the "Test Bed" used for running the AppComponent unit tests doesn't declare the FunkyTestPipe.

We can fix this in the app.component.spec.ts file, in the beforeEach call, change:

+- declarations: [AppComponent, FunkycasePipe],
-- declarations: [AppComponent],
Enter fullscreen mode Exit fullscreen mode

Why did you not get this error when running the app? This is because the ng generate pipe command updated the app.module.ts and added the FunkyTestPipe to the declarations array in the module metadata object.

Back to TOC.

A new component

Let's create a new component:

ng g component foobar -st
Enter fullscreen mode Exit fullscreen mode

This time, we've used the shorthand syntax:

  • g is short for generate
  • -s is short for --inline-styles
  • -t is short for --inline-templates

Two files are generated:

  • into-angular/projects/hello/src/app/foobar/foobar.component.ts
  • into-angular/projects/hello/src/app/foobar/foobar.component.spec.ts

And the AppModule definition in app.module.ts file is updated to add the FoobarComponent to its list of declarations.

@NgModule({
  declarations: [AppComponent, FunkycasePipe, FoobarComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

What this means is, that the FoobarComponent belongs to the AppModule .

As we can see, we also have FunkycasePipe in the declarations metadata field. Also, an Angular component is really a special case of what is known as a "directive". Components, pipes, and directives are known as declarables.

Every component, pipe, and directive should belong to one and only one Angular module. This is because Angular uses NgModules to group those three types of things and allow those to refer to each other.

So, since both the AppComponent and FunkycasePipe belong to the AppModule, we can reference the FunkycasePipe as funkycase pipe from inside the HTML template of the AppComponent.

Open the app.component.ts file. There is no import statement for FunkycasePipe in that file, but the HTML template has the expression {{ name | funkycase }}. And it works!

Remove FunkycasePipe from the AppModule's declrations field and see what happens.

ERROR in projects/hello/src/app/app.component.ts:8:20 - error NG8004: No pipe found with name 'funkycase'.

8       <p>{{ name | funkycase }}</p>
Enter fullscreen mode Exit fullscreen mode

Revert that change to fix.

Let's use our new component inside the AppComponent. Because the selector metadata field for FoobarComponent is set to app-foobar, we only need to append the following to the AppComponent template:

<app-foobar></app-foobar>
Enter fullscreen mode Exit fullscreen mode

Run ng test and ng e2e, just in case we broke anything. Everything should check out.

Back to TOC.

Repeating UI elements

Right now, our FoobarComponent just display the text "foobar works!". Let's go work on it.

Change the template to:

<ul>
  <li *ngFor="let item of items">{{ item }}</li>
</ul>
Enter fullscreen mode Exit fullscreen mode

And add the items property:

items = ['foo', 'bar'];
Enter fullscreen mode Exit fullscreen mode

The ngFor is a structural directive. It is called an structural directive because it affect the structure of the view by creating or removing elements.

Back to TOC.

Conditionally rendering elements

Another structural directive is ngIf, let's see it in action by replacing he FoobarComponent template with:

<div>
  <h3>Your options</h3>
  <ul>
    <li *ngFor="let item of items">{{ item }}</li>
  </ul>

  <div *ngIf="selectedItem == ''">
    Please type-in one of the options.
  </div>
  <div *ngIf="items.includes(selectedItem)">
    Selected option is: <strong>{{ selectedItem }}</strong>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

And adding the selectedItem property:

selectedItem = '';
Enter fullscreen mode Exit fullscreen mode

At this point, our app should look like this:

App screenshot with Foobar component in view

Back to TOC.

Binding our component to the DOM element

Let's add an input element where we can type-in our selection. Add this HTML to the end of the FoobarComponent template (between line 12 and 13 of previous code listing):

<div>
  <input
    #txt
    type="text"
    [value]="selectedItem"
    [attr.aria-label]="ariaLabel"
    [class]="customClasses"
    [style]="customStyles"
  />
  <button (click)="handleClick(txt.value)">Select</button>
  <p *ngIf="msg != ''">{{ msg }}</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Also add these properties in our component:

ariaLabel = 'Option to select';
customClasses="class1 class2";
customStyles = 'width: 200px; background-color: lightblue';
Enter fullscreen mode Exit fullscreen mode

We have a bunch of new concepts in this one short code snippet:

  1. The#txt syntax declares a template reference variable with the name txt, which we can use to refer to the corresponding DOM element, as done at line 10 above.
  2. The [value]="selectedItem" is a property binding. This binds the selectedItem property on our component with the value property on the DOM element. The binding is one-way. When the selectedItem property changes, the DOM property is updated. But when the value DOM property is updated as a result of user interaction on the web page, that change does NOT automatically updates the selectedItem property on the component.
  3. The [attr.aria-label]="ariaLabel" is an attribute binding. The reason for having this type of binding is that there are some HTML element attributes that do not have corresponding DOM properties. Like. for example, the aria-label attribute.
  4. The [class]="customClasses" is a class binding. This binds the customClasses property of the component, which in this example is a space seprated list of CSS classes, to the classes that are actually attached to the DOM element.
  5. The [style]="customStyles" is a style binding. This binds the customStyles property of the component, which contains a semicolon seprated list of CSS styles, to the DOM element.
  6. The (click)="handleClick(txt.value)" is an event binding. This binds the handleClick method on the component with the click event on the DOM element. So every time the click event fires, our handleClick method gets invoked. Notice how we have used the template reference variable txt to access the DOM element here.

Back to TOC.

Passing data between components

Like we discussed before, a component is responsible for a chunk of our applications' user interface. For better mantainability, components keep largely to themselves and has no idea what's going on outside themselves. But when we compose our application out of a hierarchy of components, we need a way to send data into a component and get data out of that component. For this, Angular provides the Input and Output decorators:

Components take input and fires output events

Let's create a new component to understand how this works in practice:

ng g component baz -st
Enter fullscreen mode Exit fullscreen mode

Replace the generated Baz component code in baz.component.ts with this:

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-baz',
  template: `
    <div>
      <p>{{ msg }}</p>
      <input #txt type="text" />
      <button (click)="handleClick(txt.value)">Ok</button>
    </div>
  `,
  styles: [
    'div{margin: 10px; padding: 5px; width: 200px; border: 1px solid grey}',
  ],
})
export class BazComponent {
  @Input() msg: string = 'Default prompt';
  @Output() nameSelected = new EventEmitter<string>();

  handleClick(val: string) {
    this.nameSelected.emit(val);
  }
}
Enter fullscreen mode Exit fullscreen mode

And use the Baz component from inside the App component like this:

<app-baz [msg]="msgIn" (nameSelected)="onNameSelected($event)"></app-baz>
<p>
  Name received from the baz component:<br />
  <code>{{ nameOut }}</code>
</p>
Enter fullscreen mode Exit fullscreen mode

You need to append the above HTML code to the template in the app.component.ts file, and the below TypeScript code to the class body.

msgIn = 'Please enter your name';
nameOut = '<No Ourput from child component yet>';

onNameSelected(name: string) {
  this.nameOut = name;
}
Enter fullscreen mode Exit fullscreen mode

The Baz component displays a customisable message, reads user text input, and provides an Ok button that when clicked emits a custom event with what the user entered in the text box. Let's see it in action:

The baz component takes an input and produces an output

The displayed message is customisable by the parent component.

@Input() msg: string = 'Default prompt'
Enter fullscreen mode Exit fullscreen mode

The Input decorator marks msg as an input property.

To the parent component, msg is "bindable" just like a DOM property:

<app-baz [msg]="msgIn"></app-baz>
Enter fullscreen mode Exit fullscreen mode

The child component fires output events and the parent component binds to these events using the familiar event binding syntax.

<app-baz (nameSelected)="onNameSelected($event)"></app-baz>
Enter fullscreen mode Exit fullscreen mode

The Output decorator is used on a field in the child component to declare that field as an output event.

  @Output() nameSelected = new EventEmitter<string>();
Enter fullscreen mode Exit fullscreen mode

When the child component has data it needs to ouput, it uses nameSelected and emit an event with that data as the payload:

this.nameSelected.emit(val);
Enter fullscreen mode Exit fullscreen mode

Back to TOC.

What's next

The code for our workspace as at the end of this post can be found under the p02 tag in the nalaka/into-angular repository.

GitHub logo nalaka / into-angular

Code for the dev.to series "A tour of Angular for web developers"

In the next post we will see how to create services that interact with the world outside our component and how to inject them where they are needed.

Following this, we will continue our tour by looking at routing, forms, material styling, animation, internationalization, accessibility, testing, and keeping our applications up-to date as Angular itself evovles.

Back to TOC.

Top comments (0)