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()
)
}
}
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...
}
}
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. |
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))
}
}
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)
}
}
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 },
)
}
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) {}
}
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()
)
}
}
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 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
}
}
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"
})
}
}
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
}
}
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()
}
}
}
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)
}
}
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 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)
}
}
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)
}
}
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
}
}
}
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...
}
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
}))
)
}
}
These features rely on unstable APIs that could break at any time.
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))