Angular standalone components, directives, pipes

Angular standalone components, directives, pipes

The standalone components are getting more and more attention in the Angular world. Even the creators encourage people to use them by default. In this article, I'll show how to use standalone components, directives and pipes.

Setup

If unsure how to set up Angular with Jest please refer to the article: barcioch.pro/angular-with-jest-setup.

The difference

Before Angular@14 everything had to be defined in the NgModules. They are contextual containers for the application features. All external modules have to be imported in imports section, providers in providers section and modules' components in declarations section. If any of the module's component is to be used in the different place of the application, it has to be exported in exports section.

@NgModule({
  declarations: [
    AppComponent,
    InternalComponent,
  ],
  imports: [
    ButtonsModule
  ],
  providers: [
    MyService
  ],
  exports: [
    AppComponent
  ]
})
export class AppModule { }

With the introduction of standalone features (components, directives, pipes) they became obsolete. To define the feature as standalone, you have to add standalone: true property to the @Component, @Pipe or @Directive decorator. Now, each feature is self-contained and can have its own imports section.

@Component({
  selector: 'app-test',
  standalone: true,
  imports: [MyDirective, MyPipe, MyComponent],
  providers: [MyService]
})
export class TestComponent {

}

Basic examples

Pipe

This example pipe reverses the order of the passed string.

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

@Pipe({
  name: "reverseText",
  standalone: true,
})
export class ReverseTextPipe implements PipeTransform {
  transform(value: string | undefined | null): string {
    if (value === null || value === undefined) {
      return '';
    }

    return value.split('').reverse().join('');
  }
}

Test it

The standard pipe test.

import { ReverseTextPipe } from './reverse-text.pipe';

describe('ReverseTextPipe', () => {
  describe('when pipe is created', () => {
    let pipe: ReverseTextPipe;

    beforeAll(() => {
      pipe = new ReverseTextPipe();
    });

    it.each([
      { input: undefined, text: 'undefined' },
      { input: null, text: 'null' },
      { input: '', text: 'empty string' },
    ])('should transform $text to empty string', ({ input, text }) => {
      expect(pipe.transform(input)).toBe('');
    });

    it('should reverse the "reverse me" text to "em esrever"', () => {
      expect(pipe.transform('reverse me')).toBe('em esrever');
    });
  });
});

Directive

This example directive adds data-test-id attribute with static aaa-bb-1 value.

import { Directive, HostBinding, HostListener, inject } from '@angular/core';
import { Logger } from '../services/logger';

@Directive({
  selector: '[test-id]',
  standalone: true,
})
export class TestIdDirective {
  @HostBinding("attr.data-test-id") testId = 'aaa-bb-1';
}

Test it

The test uses a standalone TestComponent as a wrapper. There is no TestBed.configureTestingModule() call since there is no NgModule.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TestIdDirective } from './test-id.directive';

describe('TestIdDirective', () => {
  let fixture: ComponentFixture<TestComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
  });

  describe('when component with directive is initialized', () => {
    it('should add data-test-id attribute to the element', () => {
      expect(getElement().nativeElement.getAttribute('data-test-id')).toBe('aaa-bb-1');
    });
  });

  const getElement = (): DebugElement => {
    return fixture.debugElement.query(By.css('[data-test="element-with-id"]'));
  }
});


@Component({
  selector: 'app-test',
  standalone: true,
  imports: [TestIdDirective],
  template: `
    <div data-test="element-with-id" test-id></div>`,
})
export class TestComponent {

}

Component

A simple component that wraps passed content in <h1> tags.

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

@Component({
  selector: 'app-h1',
  standalone: true,
  template: `
    <h1 data-test="header-component">
      <ng-content></ng-content>
    </h1>`,
})
export class HeaderComponent {

}

Test it

The test uses a standalone TestComponent as a wrapper. There is no TestBed.configureTestingModule() call since there is no NgModule.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { HeaderComponent } from './header.component';

describe('HeaderComponent', () => {
  let fixture: ComponentFixture<HeaderComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(TestComponent);
    fixture.detectChanges();
  });

  describe('when component is initialized', () => {
    it('should display header component', () => {
      expect(getElement()).toBeTruthy();
    });

    it('should display header component content', () => {
      expect(getElement().nativeElement.textContent).toBe('the content');
    });
  });

  const getElement = (): DebugElement => {
    return fixture.debugElement.query(By.css('[data-test="header"]'));
  }
});


@Component({
  selector: 'app-test',
  standalone: true,
  imports: [HeaderComponent],
  template: `
    <app-h1 data-test="header">the content</app-h1>`,
})
export class TestComponent {

}

Advanced example

With the NgModule is gone (feature module), we no longer have its context. So where to place the providers, store and lazy loading? In the Route or in the Component. Personally, I'm against this approach, but right now, there is no alternative.

In the next examples, I'll be using the standalone DummyComonent.

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

@Component({
  selector: 'app-dummy',
  standalone: true,
  imports: [RouterOutlet],
  template: `<router-outlet></router-outlet>`,
})
export class DummyComponent {

}

Bootstrapping standalone component

Bootstrapping with NgModule

Bootstrapping with NgModule requires putting a component in bootstrap property. It takes an array, but there is no point in having multiple application entry points.

// app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

In the imports property there is an imported AppRoutingModule, which contains definitions of initial app routes.

// app-routing.module.ts

import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { DummyComponent } from './components/dummy.component';

export const routes: Routes = [
  {
    path: `dummy`,
    component: DummyComponent,
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {
}

The main.ts file bootstraps the application using the AppModule.

// main.ts

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

Bootstrapping with standalone component

The bootstrapped component has to be declared as standalone. I'll also add router-outlet and import RouterOutlet.

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

@Component({
  selector: 'app-root',
  standalone: true,
  template: '<router-outlet></router-outlet>',
  imports: [RouterOutlet]
})
export class AppComponent {
}

To achieve the same bootstrapping result with the standalone component, the Angular team provided the new bootstrapApplication function.

// main.ts

import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from './app/app.component';
import { provideRouter } from "@angular/router";
import { routes } from './app/app-routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
  ]
});

The first argument is our main standalone component. The second is a ApplicationConfig with a providers property. You can provide Router or any other global dependencies that you might need.

The provideRouter function enables Router functionality and tree-shaking, which was not possible before.

Lazy-loaded standalone components

To lazy load a standalone component there's a new route property loadComponent that is very similar to the loadModule.

import { Routes } from '@angular/router';
import { DummyComponent } from './components/dummy.component';

export const routes: Routes = [
  {
    path: `main`,
    component: DummyComponent,
  },
  {
    path: 'lazy-route',
    loadComponent: () => import('./components/dummy.component').then(c => c.DummyComponent)
  }
];

Lazy-loaded routes

The following examples will be achieving the /feature1/feature2 routing. Both features are lazy-loaded.

Nested NgModule routing

When using the feature module we import the feature routing module that imports RouterModule with passed routes. Lazy loading is achieved by utilizing loadChildren property.

Application structure using the NgModules

The AppModule imports the AppRoutingModule.

// app.module.ts

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { DummyComponent } from './components/dummy.component';

@NgModule({
  declarations: [
    AppComponent,
    DummyComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

The AppRoutingModule defines the lazy route feature1 that points to Feature1Module.

// app-routing.module.ts

import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { DummyComponent } from './components/dummy.component';

export const routes: Routes = [
  {
    path: `main`,
    component: DummyComponent,
  },
  {
    path: 'feature1',
    loadChildren: () => import('./feature1/feature1.module').then(m => m.Feature1Module)
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {
}

The Feature1Module imports Feature1RoutingModule.

// feature1.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Feature1Component } from './feature1.component';
import { Feature1RoutingModule } from './feature1-routing.module';

@NgModule({
  declarations: [Feature1Component],
  imports: [
    CommonModule,
    Feature1RoutingModule
  ],
})
export class Feature1Module {
}

The Feature1RoutingModule points directly to Feature1Component and defines a lazy child route feature2 that points to Feature2Module.

// feature1-routing.module

import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { Feature1Component } from './feature1.component';

export const routes: Routes = [
  {
    path: ``,
    component: Feature1Component,
    children: [
      {
        path: 'feature2',
        loadChildren: () => import('./feature2/feature2.module').then(m => m.Feature2Module)
      }
    ]
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class Feature1RoutingModule {
}

The Feature2Module imports Feature2RoutingModule.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Feature2Component } from './feature2.component';
import { Feature2RoutingModule } from './feature2-routing.module';

@NgModule({
  declarations: [Feature2Component],
  imports: [
    CommonModule,
    Feature2RoutingModule
  ],
})
export class Feature2Module {
}

The Feature2RoutingModule points directly to Feature2Component

import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { Feature2Component } from './feature2.component';

export const routes: Routes = [
  {
    path: ``,
    component: Feature2Component,
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class Feature2RoutingModule {
}

Nested standalone component routing

With the NgModule is gone all we need to do is import the routes with the old loadChildren property.

Application structure using the standalone components

The main.ts file bootstraps the application with the main routes.

// main.ts

import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from './app/app.component';
import { provideRouter } from "@angular/router";
import { routes } from './app/app-routes';


bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
  ]
});

The main app routes file defines lazy route feature1 that points to feature1-routes file.

// app-routes.ts

import { Routes } from '@angular/router';
import { DummyComponent } from './components/dummy.component';

export const routes: Routes = [
  {
    path: `main`,
    component: DummyComponent,
  },
  {
    path: 'feature1',
    loadChildren: () => import('./feature1/feature1-routes').then(r => r.routes)
  }
];

The feature1 points directly to Feature1Component. Also, it defines the lazy child route feature2 that points to feature2-routes file.

// feature1-routes.ts

import { Routes } from '@angular/router';
import { Feature1Component } from './feature1.component';

export const routes: Routes = [
  {
    path: ``,
    component: Feature1Component,
    children: [
      {
        path: 'feature2',
        loadChildren: () => import('./feature2/feature2-routes').then(r => r.routes)
      }
    ]
  },
];

The feature2 points directly to Feature2Component.

// feature2-routes.ts

import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { Feature2Component } from './feature2.component';

export const routes: Routes = [
  {
    path: ``,
    component: Feature2Component,
  },
];

And that's all in terms of routing. The NgModules are gone.

Test it

To test the routing you still need to use the TestBed. I'm using configureTestingModule method without calling compileComponents() since the latter is not needed. Also, the RouterTestingHarness will come in handy while testing the routes. The routes are imported directly from the main app route file.

import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingHarness } from '@angular/router/testing';
import { provideRouter } from '@angular/router';
import { routes } from './app-routes';


describe('App Routing', () => {
  let harness: RouterTestingHarness;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [provideRouter(routes)]
    });

    harness = await RouterTestingHarness.create();
  });

  describe('when testing module is initialized', () => {
    describe('and user enters "/main" route', () => {
      beforeEach(async () => {
        await harness.navigateByUrl('/main');
      });

      it('should render DummyComponent', () => {
        const element = harness.fixture.debugElement.query(By.css('app-dummy'));
        expect(element).toBeTruthy()
      });
    });

    describe('and user enters "/feature1" route', () => {
      beforeEach(async () => {
        await harness.navigateByUrl('/feature1');
      });

      it('should render Feature1Component', () => {
        const element = harness.fixture.debugElement.query(By.css('app-feature1'));
        expect(element).toBeTruthy()
      });
    });

    describe('and user enters "/feature1/feature2" route', () => {
      beforeEach(async () => {
        await harness.navigateByUrl('/feature1/feature2');
      });

      it('should render Feature1Component', () => {
        const element = harness.fixture.debugElement.query(By.css('app-feature1'));
        expect(element).toBeTruthy()
      });

      it('should render Feature2Component', () => {
        const element = harness.fixture.debugElement.query(By.css('app-feature2'));
        expect(element).toBeTruthy()
      });
    });
  });
});

Providers

Without the NgModule providers property, there are two places to declare them unless providedIn: 'root' is used.

1 - Component providers - the component and its all descendants have access to the provider (through Component Injector). Just use the providers property of @Component decorator.

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

  @Component({
    selector: 'app-dummy',
    standalone: true,
    imports: [RouterOutlet],
    providers: [MyService],
    template: `<router-outlet></router-outlet>`,
  })
  export class DummyComponent {

  }

2 - Route providers - all the route descendants have access to the provider (through Route Injector). Just use the providers property of Route interface.

export const routes: Routes = [
  {
    providers: [MyService],
    path: 'feature1',
    loadChildren: () => import('./feature1/feature1-routes').then(r => r.routes)
  }
]

Source code

gitlab.com/barcioch-blog-examples/015-angul..