Exploring the Angular Effects API

stupidawesome

Michael Muscat

Posted on March 8, 2020

Exploring the Angular Effects API

This article covers some of the finer points to help you get more out of Angular Effects. You can also jump forward to see a worked example that puts many of these concepts into practice.


This is part V in a series on Reactive State in Angular. Read Part IV: Extending Angular Effects with effect adapters


Multiple bindings

When writing effects, we can bind their emissions to properties on the connected component. We can also choose to bind multiple effects to a single property. Use this technique when properties receive values from several mutually exclusive observable sources.

@Component()
export class AppComponent {
    count = 0

    @Effect("count")
    incrementCount(state: State<AppComponent>) {
        return state.count.pipe(
            take(1),
            increment(1),
            repeatInterval(1000)
        )
    }

    @Effect("count")
    multiplyCount(state: State<AppComponent>) {
        return state.count.pipe(
            take(1),
            multiply(2),
            repeatInterval(5000)
        )
    }
}

In this example count will be incremented once every second, with its value being multiplied by two every five seconds. Each effect runs independently of one another.

Assignment bindings

Property bindings bind a value stream to a single property. Assignment bindings are like property bindings, except we can update multiple properties on the connected component at the same time. Effects with assignment bindings must emit objects that match the partial signature of the component it is bound to.

@Component()
export class AppComponent {
    name = ""
    version = undefined

    @Effect({ assign: true })
    incrementCount(state: State<AppComponent>) {
        return of({
            name: "Angular",
            version: {
                major: 9,
                minor: 0,
                patch: 0
            }
        })
    }
}

Under the hood assignment bindings use Object.assign() to assign values emitted from each effect.

Module effects

Angular Effects can be used inside modules to create global effects. This is useful for mapping websocket channels to global state, global event handling or other side effects that do not depend on local component state.

@Injectable({ providedIn: "root" })
export class GlobalEffects {
    websocket = new WebSocketSubject()

    @Dispatch(Authorized)
    authorized() {
        return this.websocket.pipe(
            filter(event => event.type === "AUTHORIZED")
        )
    }
}
@NgModule({
    providers: [Effects]
})
export class AppModule {
    constructor(connect: Connect) {
        connect(this)
    }
}

NOTE: See Effect adapters: Integrating Angular Effects with @ngrx/store for more information about @Dispatch().

This example shows how we can dispatch an action when the websocket connection receives messages matching a given pattern. Keep in mind that when using effects with modules, some options such as markDirty and detectChanges will have no effect.

Special injection tokens

Locally provided effect services have access to the component's Injector. This means we can inject special tokens that are only available to the component, such as ElementRef, TemplateRef, Renderer and ViewContainerRef. When working with components we can extract these dependencies into our effect services.

interface ButtonLike {
    disabled: boolean
    press: HostEmitter<MouseEvent>
}

@Injectable()
export class ButtonEffect {
    constructor(private elementRef: ElementRef, private renderer: Renderer2) {}

    @Effect("press")
    allowClick(state: State<ButtonLike>) {
        const eventPattern = handler => this.renderer.listen(this.elementRef.nativeElement, "click", handler)

        return fromEventPattern(eventPattern).pipe(
            withLatestFrom(state.disabled, (event, disabled) => disabled ? false : event),
            filter(Boolean)
        )
    }
}
@Component({
    providers: [Effects, ButtonEffect]
})
export class ButtonComponent implements ButtonLike {
    @Input()
    disabled = false

    @Output()
    press = new HostEmitter<MouseEvent>()

    constructor(connect: Connect) {
        connect(this)
    }
}

NOTE: Remember that you must provide your effects in the local component providers array to gain access to that component's injector.

This example demonstrates how we can use special injection tokens to extract the behaviour of a button component into a reusable trait. We are able to cleanly decouple the button's state from its behavior.

HostRef

HostRef is a special injection token that gives components the ability to observe its own state. Every effect we write is provided three arguments that are populated from this token: state, context and observer. See Getting Started: Arguments for more information.

We can inject this token in our connected components and effect services.

@Injectable()
export class AppEffects {
    context: AppComponent
    state: State<AppComponent>
    observer: Observable<AppComponent>

    constructor(hostRef: HostRef<AppComponent>) {
        this.context = hostRef.context
        this.state = hostRef.state
        this.observer = hostRef.observer
    }
}

We can also inject the parent HostRef to observe the state of a connected parent.

@Injectable()
export class ChildEffects {
    constructor(@SkipSelf() parent: HostRef<ParentComponent>) {}
}

Lastly, we can query and observe the state of connected child components.

interface ChildComponent {
    count: number
}

@Component()
export class ParentComponent {
    @ViewChild(ChildComponent, { read: HostRef })
    childRef: HostRef

    @Effect({ whenRendered: true })
    logChildCount(state) {
        return state.childRef.pipe(
            switchMap(childRef => childRef.state.count)
        ).subscribe(count => console.log(count))
    }
}

As these examples demonstrate, direct use of HostRef is useful when building tightly coupled reactive components.

Two-way observable state: Renderless select

Putting these concepts into practice, here is a working demo of a select component powered by Angular Effects. It demonstrates how we can use HostRef to create two-way observable state, putting components together with renderless traits, and more.

Experimental features

These features rely on unstable APIs that could break at any time.

Zoneless change detection

Zoneless change detection depends on experimental Ivy renderer features. To enable this feature, add the USE_EXPERIMENTAL_RENDER_API provider to your root module.

Zones can be disabled by commenting out or removing the following line in your app's polyfills.ts:

import "zone.js/dist/zone" // Remove this to disable zones

In your main.ts file, set ngZone to "noop".

platformBrowserDynamic()
    .bootstrapModule(AppModule, { ngZone: "noop" }) // set this option
    .catch(err => console.error(err))

Global connect hook

Global hooks are a new feature in Angular. By using some private APIs we don't have to explicitly inject the Connect service to connect our components.

@Component({
    providers: [Effects]
})
export class AppComponent {
    count = 0
    // global connect hook
    constructor() {
        connect(this)
    }

    @Effect("count")
    incrementCount() {
        // etc
    }
}

Connect API

NOTE: Under the hood, this the mechanism that makes effects run. This is not a stable API so use it at your own risk.

If you are familiar with APP_INITIALIZER, it's like that except for components and directives. To create a service that is automatically instantiated when the component or directive is "connected" (ie. by calling connect()), add a multi provider to your providers array similar to this one.

@Injectable()
export class MyConnectedService {
    constructor(hostRef: HostRef) {}
}

export const INITIALIZERS = [{
    provide: HOST_INITIALIZER,
    useValue: MyConnectedService,
    multi: true
}]

export const CONNECTED = [
    MyConnectedService,
    CONNECT,
    INITIALIZERS
]

@Component({
    providers: [CONNECTED]
})
export class MyComponent {
    constructor(connect: Connect) {
        connect(this)
    }
}

When the component is created in this example, MyConnectedService will be instantiated and have access to the HostRef.

Diving deep into Angular

Thank you for reading this series on Angular Effects. In the final article of this series, I will take you on a deep dive into the inner workings of Angular Effects and how it's made possible through some rarely used APIs, some experimentation and the hard work of the Angular team to bring us stable Ivy.

Next in this series

💖 💪 🙅 🚩
stupidawesome
Michael Muscat

Posted on March 8, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related