kaliumxyz / ng-effects

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

ng-effects

Reactive local state management for Angular.

interface AppState {
    count: number
}

@Component({
    selector: "app-root",
    template: `
        <div>Count: {{count}}</div>
    `,
    providers: [HOST_EFFECTS],
})
export class AppComponent implements AppState {

    @Input()
    count: number = 0

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

    @Effect("count", { markDirty: true })
    incrementCount(state: State<AppState>) {
        return timer(1000).pipe(
            switchMapTo(state.count),
            take(1),
            increment(1),
            repeat()
        )
    }
}

Installation

Install ng-effects via NPM

npm install ng-effects

Usage

Initialize effects

Effects are initialized through a Connect service. Inject Connect into your component or directive and call it only after you have initialized all variables with default values. Connect can only be injected when HOST_EFFECTS or effects() are provided.

@Component({
    providers: [HOST_EFFECTS]
})
export class AppComponent {
    count: number
    name: string

    constructor(connect: Connect) {
        this.count = 0 // Always provide default values first
        this.name = undefined // Should be initialized even if value is undefined

        connect(this) // Should always be last statement and called in constructor
    }

    @Effect(options)
    someEffect() {
        // etc...
    }
}

Effect options

Effects can be configured through the decorator or factory function.

Option Type Description

bind

string

When configured, maps values emitted by the effect to a property of the same name on the host context. This option is ignored if the effect does not return an observable.

apply

boolean

When configured, maps the properties of partial objects emitted by the effect to matching properties on the host context. This option is ignored if the effect does not return an observable.

markDirty

boolean

When set to true, schedule change detection to run whenever a bound effect emits a value.

detectChanges

boolean

When set to true, detect changes immediately whenever a bound effect emits a value.

whenRendered

boolean

When set to true, the effect is not initialised until the host element has been mounted to the DOM.

adapter

Type<any>

Hook into effects with a custom effect handler. For example, to dispatch all values emitted by the effect as actions to a global store.

Default options

Default behaviour can be configured in the effect() provider

@Component({
    providers: [
        effects([AppEffects], { markDirty: true })
    ]
})
export class AppComponent {}

Host effects

In simple cases, effects can be provided directly on the host. If no other effects need to be provided, you only have to pass in HOST_EFFECTS to the host provider.

@Component({
    providers: [HOST_EFFECTS]
})
export class AppComponent implements AppState {

    count: number

    constructor(connect: Connect) {
        this.count = 0
        connect(this)
    }

    @Effect()
    logCount(state: State<AppState>) {
        return state.count.subscribe(count => console.log(count))
    }
}

Effect services

Effects can be extracted into injectable services. These must be provided in the local providers (or viewProviders) array. Effects can be reused this way.

interface AppState {
    count: number
}

@Injectable()
export class AppEffects implements Effects<AppComponent> {
    @Effect()
    count(state: State<AppState>) {
        return timer(1000).pipe(
            switchMapTo(state.count),
            take(1),
            increment(1),
            repeat()
        )
    }
}

@Injectable()
export class OtherEffects implements Effects<Other> {
    // etc...
}

@Component({
    selector: "app-root",
    template: `
        <div>Count: {{count}}</div>
    `,
    providers: [effects([AppEffects, OtherEffects])],
})
export class AppComponent implements AppState {

    count: number

    constructor(connect: Connect) {
        this.count = 0

        connect(this)
    }
}

Examples

createEffect

Alternative syntax for effect declaration.

@Component()
export class AppComponent implements AppState {
    incrementCount = createEffect(
        (state: State<TestState>, ctx: TestComponent) =>
            timer(1000).pipe(
                switchMapTo(state.count),
                take(1),
                increment(1),
                repeat()
            ),
        { bind: "count", markDirty: true },
    )
}

Special injection tokens

Injected services share the same injector scope as their host. Special tokens such as ElementRef and Renderer2 can be injected.

@Injectable()
export class AppEffects implements Effects<AppComponent> {
    constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
}

HostRef

A reference to the host context can be injected using HostRef<T>

@Injectable()
export class AppEffects implements Effects<AppComponent> {
    host: AppComponent
    constructor(hostRef: HostRef<AppComponent>) {
        this.host = hostRef.instance
    }
}

Property bindings

Effects can be bound a named property on the host context by setting the bind property. This property is updated whever the effect emits a new value. Throws an error if the property does not exist.

@Injectable()
export class AppEffects implements Effects<AppComponent> {
//  Alternatively:
//  @Effect({ bind: count, markDirty: true }
    @Effect("count", { markDirty: true })
    count(state: State<AppState>) {
        return timer(1000).pipe(
            switchMapTo(state.count),
            increment(1),
            take(1),
            repeat()
        )
    }
}

Implicit bindings

By default, bindings are created implicitly when the name of an effect matches the name of any own property in the host context. This behavior can be disabled by providing USE_STRICT_EFFECTS in the root module of your application.

@Injectable()
export class AppEffects implements Effects<AppComponent> {
//  Functionally equivalent to
//  @Effect("count", { markDirty: true })
    @Effect({ markDirty: true })
    count(state: State<AppState>) {
        return timer(1000).pipe(
            switchMapTo(state.count),
            increment(1),
            take(1),
            repeat()
        )
    }
}

Multiple bindings

Multiple effects can be bound to the same property.

@Component()
export class AppComponent implements AppState {
    count: number

    @Effect("count", { markDirty: true })
    incrementCount(state: State<AppState>) {
        // implementation
    }

    @Effect("count", { markDirty: true })
    multiplyCount(state: State<AppState>) {
        // implementation
    }
}

Partial bindings

If the effect should update multiple properties on the host context at the same time, use the apply option.

@Component()
export class AppComponent implements AppState {
    @Effect({ apply: true })
    assignMany(state: State<AppState>) {
        return of({
            prop1: "value1",
            prop2: "value2"
        })
    }
}

Unsafe bindings

Effects with bindings are inferred from function arguments. Omitting these arguments will cause a type error. This error can be suppressed by passing any to the effect decorator.

@Injectable()
export class AppEffects implements Effects<AppComponent> {
    @Effect<any>("name")
    suppressTypeChecking() {
        // do unsafe binding
    }
}

Side effects

Effects that do not bind a property, or return a subscription/teardown function, are treated as side effects.

@Injectable()
export class AppEffects implements Effects<AppComponent> {
    @Effect()
    logCountWithObservable(state: State<AppState>) {
        return state.count.pipe(
            tap(count => console.log(count))
        )
    }

    @Effect()
    logCountWithSubscription(state: State<AppState>) {
        return state.count.subscribe(count => console.log(count))
    }

    @Effect()
    logCountWithTeardown(state: State<AppState>) {
        const sub = state.count.subscribe(count => console.log(count))
        return function () {
            sub.unsubscribe()
        }
    }
}

Output bindings

Effects can be easily connected to host context outputs.

@Injectable()
export class AppEffects implements Effects<AppComponent> {
    @Effect()
    countChange(state: State<TestState>, context: AppComponent) {
        return state.count.changes.subscribe(context.countChange)
    }
}

Template event bindings

Component template events can be exposed via State.

@Injectable()
export class AppEffects {
    @Effect()
    handleTemplateClick(state: State<AppComponent>) {
        return state.clicked.subscribe(event => console.log(`click:`, event))
    }
}

@Component({
    selector: "app-root",
    template: `<div (click)="clicked = $event">Click me<div>`,
    providers: [effects(AppEffects)]
})
export class AppComponent {
    clicked: MouseEvent

    constructor(connect: Connect) {
        this.clicked = undefined
        connect(this)
    }
}

Alternatively, use an event emitter.

@Injectable()
export class AppEffects {
    @Effect()
    handleTemplateClick(state: State<AppComponent>, context: AppComponent) {
        return context.clicked.subscribe(event => console.log(`click:`, event))
    }
}

@Component({
    selector: "app-root",
    template: `<div (click)="clicked.next($event)">Click me<div>`,
    providers: [effects(AppEffects)]
})
export class AppComponent {
    clicked: Subject<MouseEvent>

    constructor(connect: Connect) {
        this.clicked = new Subject()
        connect(this)
    }
}

Host listener bindings

Host listener events can be exposed via State.

@Injectable()
export class AppEffects {
    @Effect()
    handleHostClick(state: State<AppComponent>) {
        return state.click.subscribe(event => console.log(`click:`, event))
    }
}

@Component({
    selector: "app-root",
    template: `<div (click)="clicked = $event">Click me<div>`,
    providers: [effects(AppEffects)],
    host: {
        "(click)": "clicked = $event"
    }
})
export class AppComponent {
    clicked: MouseEvent

    constructor(connect: Connect) {
        this.clicked = undefined
        connect(this)
    }
}

Alternatively, use an event emitter.

@Injectable()
export class AppEffects {
    @Effect()
    handleHostClick(state: State<AppComponent>, context: AppComponent) {
        return context.clicked.subscribe(event => console.log(`click:`, event))
    }
}

@Component({
    selector: "app-root",
    template: `Click me`,
    providers: [effects(AppEffects)],
    host: {
        "(click)": "clicked.next($event)"
    }
})
export class AppComponent {
    clicked: Subject<MouseEvent>

    constructor(connect: Connect) {
        this.clicked = new Subject()
        connect(this)
    }
}

Query bindings

All component queries (ViewChild, ViewChildren, ContentChild, ContentChildren) can be observed from State.

@Injectable()
export class ChildEffects {
    @Effect({ whenRendered: true })
    withContentChild(state: State<ChildComponent>) {
        return context.contentChild.subscribe(
            contentChild => console.log(contentChild)
        )
    }

    @Effect({ whenRendered: true })
    withContentChildren(state: State<ChildComponent>, context: AppComponent) {
        return context.contentChildren.subscribe(
            contentChildren => console.log(contentChildren)
        )
    }

    @Effect({ whenRendered: true })
    withViewChild(state: State<ChildComponent>) {
        return context.viewChild.subscribe(
            viewChild => console.log(viewChild)
        )
    }

    @Effect({ whenRendered: true })
    withViewChildren(state: State<ChildComponent>) {
        return context.viewChildren.subscribe(
            viewChildren => console.log(viewChildren)
        )
    }
}

@Component({
    selector: "app-child",
    template: `
        <app-child>Projected</app-child>
        <ng-content>Content</ng-content>
    `,
    providers: [effects(ChildEffects)],
    queries: {
        contentChild: new ContentChild(ChildComponent),
        contentChildren: new ContentChildren(ChildComponent),
        viewChild: new ViewChild(ChildComponent),
        viewChildren: new ViewChildren(ChildComponent),
    }
})
export class ChildComponent {
    contentChild: ChildComponent
    contentChildren: QueryList<ChildComponent>
    viewChild: ChildComponent
    viewChildren: QueryList<ChildComponent>

    constructor(connect: Connect) {
        this.contentChild = undefined
        this.contentChildren = undefined
        this.viewChild = undefined
        this.viewChildren = undefined

        connect(this)
    }
}

DOM manipulation

Effects can be deferred until after the component has been rendered to the DOM tree. Combine with teardown logic to perform any DOM cleanup when the host is destroyed.

@Injectable()
export class AppEffects {
    constructor(private elementRef: ElementRef) {}

    @Effect({ whenRendered: true })
    mounted(state: State<AppComponent>, context: AppComponent) {
        const instance = thirdPartyLib.mount(this.elementRef.nativeElement)
        return function () {
            // cleanup logic
        }
    }
}

Compose multiple observable sources

Observable services can be injected, then composed. For example, compose http services when inputs change, or map global state to local state.

@Injectable()
export class AppEffects {
    constructor(private http: HttpClient, private store: Store<any>) {}

    @Effect("activeUser", { markDirty: true })
    selectActiveUser(state: State<AppComponent>) {
        return this.store.pipe(
            select(store => store.activeUser)
        )
    }

    @Effect()
    dispatchForm(state: State<AppComponent>, context: AppComponent) {
        return context.formData.valueChanges.subscribe(payload => {
            this.store.dispatch({
                type: "FORM_UPDATED",
                payload
            })
        })
    }

    @Effect()
    fetchUsers(state: State<AppComponent>, context: AppComponent) {
        return state.userId.changes.pipe(
            switchMap(userId => this.http.get<Users>(`https://example.com/users/${userId}`).pipe(
                catchError(error => {
                    console.error(error)
                    return NEVER
                })
            ))
        ).subscribe(context.usersFetched)
    }
}

@Component()
export class AppComponent {
    @Input() userId: string
    @Output() usersFetched: EventEmitter<Users>
    activeUser: User
    formData: FormGroup

    // etc...
}

Custom Effect Handlers

An effect handler can be passed in to do additional processing after the observable has emitted a value. This can be useful for adding a dispatcher to automatically dispatch actions to a global state store.

@Injectable({ providedIn: "root" })
export class Dispatch implements EffectHandler<Action, Options> {
    constructor(private store: Store<any>) {}

    next(value: Action, options: Options) {
        this.store.dispatch(value)
    }
}
@Injectable()
export class AppEffects {
    @Effect(Dispatch)
    dispatchAction(state: State<AppComponent>, context: AppComponent) {
        return context.formData.valueChanges.pipe(
            map(payload => ({
                type: "FORM_UPDATED",
                payload
            }))
        )
    }
}

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))

About


Languages

Language:TypeScript 97.1%Language:JavaScript 2.0%Language:HTML 0.8%Language:CSS 0.2%