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 NgModule
s. 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)
}
]