authors: Alex Okrushko (this doc + further implementation), Kevin Elko (original implementation)
created: 2020-04-09
status: [draft | in review | approved | implemented | obsolete]
doc url: https://okrushko.dev/component-store-dd
feedback url: https://github.com/ngrx/platform/issues/2489
design doc template version: 1.0.0
basic DEMO: https://stackblitz.com/edit/ngrx-component-store-simple-demo?file=src%2Fcomponent_store.ts
DEMO with observables: https://stackblitz.com/edit/ngrx-component-store-simple-demo-with-observable?file=src%2Fcomponent_store.ts
To have an alternative to a "Service with BehaviorSubject" for state management solution that:
NgRx Team - Tim, Brandon, Wes and Mike
Jennifer is part of a larger team that is working on a Web App that consists of large fairly-independent pieces (aka "modules") of the app. While the app shares some global data (e.g. user permissions), a large chunk of data is self-contained within a module that she is developing.
Jennifer understands that the data within her module is considered to be "local state" and does not belong to ngrx/store
so she reaches for ComponentStore
to have a reactive Service that would erase the data when the User navigates to the other part of the Web App.
Jennifer is working on the app that would have multiple BookShelfComponent
components visible at the same time. BookShelfComponent
is a complex component that has child components, like individual BookComponent
s.
Each BookShelfComponent
is independent from another, and doesn't share any state with its siblings, yet each one needs to do independent requests and destroy any data it had when the component is removed.
ComponentStore
is the solution that Jennifer is reaching for.
Jennifer is working closely with Dan, who is building an independent Web App (separate from Jennifer's). Both teams that Jennifer and Dan are part of, are sharing the same design system.
Dan saw that the complex component that he needs was already built by Jennifer. Since this component was self-sufficient and didn't need any data from the global store, it was using ComponentStore
for its local state management.
Dan easily imports this component into his component tree.
One of the services used in ComponentStore
was calling another HTTP endpoints. Dan overrides that service in his providers
, but other than that Jennifer's component was "plug-and-play".
From the DX perspective, using ComponentStore
is just like using typical Services within Angular.
@Component({
selector: "book-shelf",
templateUrl: "book_shelf.component.html",
// provides 👇 Component-level Service
providers: [BooksStore]
})
export class BookShelfComponent {
readonly authors$: Observable<Author[]> =
// reactive way to 👇 read all authors
this.booksStore.getAuthors();
readonly authorsCount$: Observable<number> =
// reactive way to 👇 read transformed/derived data
this.booksStore.getAuthorsCount();
// Injects the specialized ComponentStore
// that knows how to work with book data 👇
constructor(private readonly booksStore: BooksStore) {}
addAuthor(author: Author) {
// adds Author to the local state that is kept
// within BooksStore 👇 and doesn't call APIs.
this.booksStore.addAuthor(author);
}
saveAuthor(author: Author) {
// saves Author to the backend. This is a way
// to interact with services that wrap API calls
// and persist or read data 👇
this.booksStore.saveAuthor(author);
}
}
Local State within Angular components can mean different things to different people.ComponentStore
builds off the idea that keeping too much state AND business logic within a Component makes it hard to work with (use/refactor/read) and test such a Component.
Following Single Responsibility Principles (SRP) developers try to separate that logic into Services. Further improvement is to make such Services to be push-based, which typically means these stateful Services keep state in some type of Subject
from RxJS (typically BehaviorSubject
).
Such solutions work well for local state, however it's hard to create it, and it's quite error prone. These solutions also frequently overlook race conditions: e.g. what if addTodo(newTodo)
clicked multiple times and it persists this change to the backend?
The idea of having a reactive local state in some sort of library is not new and a number of libraries were created for it, some also try to solve "ngrx/store" replacement as a Global State solution at the same time.
In fact, Mike Ryan added "Local State API" proposal way back in early 2018.
The most recent example that I saw is by Michael Hladky's ngx-rx-state
aka StateService
aka RxState
.
Goals
NoGoals:
The main idea revolves around the terms that were already popularized with Redux/NgRx ecosystems, and so maybe be familiar to the potential user of ComponentStore
.
These are:
ComponentStore
's state into something that Component
can easily consume.Note: The exact API naming is still in discussion, while the functionality is settling in.
❗️APIs for ComponentStore
allow to read the state through Observable
and to update the state either reactively (through Observable
) or imperatively (by passing a single value).
API for reading the state imperatively is not added on purpose - to promote reactive approach.
ComponentStore<T>
:
setState(state => ({...state, value: newValue}))
Updates local state within Component (newValue
is available).
This method is just a shortcut for updater(state => ({...state, value: newValue})()
updater
(alternative names could be reducer
or updater
) - updates local state imperatively or reactively through Observable.
State is destroyed/cleaned when ComponentStore
is destoyed (or a Component that is attached to it).
updater((state, newValue) => ({...state, value: newValue}))(newValue)
Update states with a single value newValue
imperatively - frequently is a result of a callback from template (e.g. on button click). Typically updater
would be in a Sevice together with business logic, and (newValue)
is called in a Component. e.g.:
// in books_store.ts
addValue = this.updater(
(state, newValue) => ({...state, value: newValue}));
// in book-shelf.component.ts
this.booksStore.addValue(newValue);
updater((state, newValue) => ({...state, value: newValue}))(newValueObservable$)
update state using values from an Observable
. Typically updater
would be in would be in a Sevice together with business logic, and (newValueObservable$)
is called in a Component. Each new Observable that is passed into the function would be updating the state.
// in books_store.ts
setId = this.updater(
(state, newId) => ({...state, id: newId}));
// in book-shelf.component.ts
id$ = this.router.paramMap.pipe(
map(params => params.get('productId')));
this.booksStore.setId(id$);
selector
(alertnative names selector
or reader
) - selects/gets local state.
Unsubscribes when ComponentStore
is destoyed (or a Component that is attached to it).
selector(state => state.value)
Returns an () => Observable
that emits new value whenever state.value
changes.
selector(selector1, selector2, (value1, value2) => value1 + value2)
Returns an () => Observable
that emits new value, that is a result of the function execution (e.g. value1 + value2
in this example), whenever selector1
or selector2
Observables emit a value.
selector(selector1, selector2, observable$ (value1, value2, observableValue) => value1 + value2 + observableValue)
Returns an () => Observable
that emits new value, that is a result of the function execution (e.g. value1 + value2 + observableValue
in this example), whenever selector1
or selector2
Observables or observable$
emit a value. Demonstrates that selectors can combine values not only from other selectors, but from another Observable sources as well (including Global Store, for example).
effect
(alertnative names effect
or asyncUpdater
) - orchestrates a side-effect, that can handle async operations, such as network calls. Unsubscribes side-effects when ComponentStore
is destoyed (or a Component that is attached to it) OR when a returned Subscription
is explicitely unsubscribed.
effect(() => observable$)()
Creates a side-effect that would be unsubscribed/destroyed only when ComponentStore
is destroyed.
this.effect(
() => this.router.paramMap.pipe(
map(params => params.get('productId')),
tap(id => save(id))
)();
While such API exists, I would recommend the exact equivalent with just adding
takeUntil(this.destroy$)
instead. It's provided byComponentStore
.this.router.paramMap.pipe( map(params => params.get('productId')), takeUntil(this.destroy), ).subscribe(id => save(id));
effect(value$ => value$.pipe(...))(newValue);
Creates a side-effect that receives new values imperatively by passing newValue
. Each newValue
is passed into values$
observable.
Unsubscribes when ComponentStore
is destroyed.
// in books_store.ts
getBook = effect(
id$ => id$.pipe(
concatMap(id => this.api.getBook(id).pipe(
tap(book => this.addBook(book)),
catchError(e => this.addError(e)),
)),
));
// in book-shelf.component.ts
this.booksStore.setId(id);
effect(value$ => value$.pipe(...))(observable$);
Creates a side-effect that receives new values emitted by observable$
. Each new value is passed into values$
observable. Every new Observable that is passed into the function would be updating the state.
Unsubscribes when ComponentStore
is destroyed. User is also able to stop a particular observable$
emissions when returned Subscription is unsubscribed.
// in books_store.ts
getBook = effect(
id$ => id$.pipe(
concatMap(id => this.api.getBook(id).pipe(
tap(book => this.addBook(book)),
catchError(e => this.addError(e)),
)),
));
// in book-shelf.component.ts
id$ = this.router.paramMap.pipe(
map(params => params.get('productId')));
this.booksStore.setId(id$);
// or
const subscription = this.booksStore.setId(id$);
// user may want to terminate sooner
// than BooksStore is destroyed by calling
subscription.unsubscribe();
ComponentStore
fits in a single file and is within a readable number of Lines of Code.
While ComponentStore
be used directly by a Component, it is not really meant to be. It includes the generic methods that deal with state/orchestration/race conditions/cleanup and Business Logic should live a separate Service.
// Sets the type of the local state 👇
export abstract class ComponentStore<T> implements OnDestroy {
// Takes initial state via constructor
constructor(initialState: T) {
this.state$ = new BehaviorSubject(initialState);
}
// Function that applies state changes. Any state changes
// are done through updater ONLY.
updater<V>(
reducerFn: (state: T, reducerArg: V) => T): (a: V) => void {
...
}
// Factory function that allows to provide data
// for side-effects
effect<V>(
generator: (origin$: Observable<V>) => Observable<unknown>
): (arg: V) => void {
...
}
// Function for selecting data for components
selector<R>(mapFn: (s: T) => R): () => Observable<R> {
...
}
}
The Business Logic would be in the Services that extend ComponentStore
, for example BookStore
, and it would use these methods to control flow/state of the data.
This is what the Developer might implement:
export interface BooksState {
books: Book[];
authors: {[name: string]: Author};
}
@Injectable()
export class BooksStore extends ComponentStore<BooksState> {
// HTTP Service for reading/persisting changes to
// the backend 👇
constructor(private readonly api: BooksHttpService) {
// 👇 initial state
super({books: [], authors: {}});
}
// Adds/replaces author in the local state only.
// This is a PUBLIC reducer
readonly addAuthor = this.updater(
(state: BooksState, author: Author) => {
return {
...state,
authors: {
...state.authors,
[author.name]: { ...author, saveState: SaveState.UNSAVED }
}
};
}
);
// These are PRIVATE reducers, and are meant to be
// used ONLY within the BooksStore. See `saveAuthor`.
private readonly savingAuthor = this.updater(savingAuthor);
private readonly saveAuthorSuccess = this.updater(saveAuthorSuccess);
// Effect example
readonly saveAuthor = this.effect<Author>(
// every time `saveAuthor` is called, its argument
// is emitted into author$ Observable.
author$ => author$.pipe(
// calls private reducer to set state to SAVING
tap(author => this.savingAuthor(author)),
// allows to choose flattening strategy and
// calls API.
concatMap(author => this.api.saveAuthor(author)),
// calls private reducer to set state to SAVED
tap((author: Author) => this.saveAuthorSuccess(author)),
)
);
// Example selectors
readonly getAuthors = this.selector(state => {
return Object.values(state.authors);
});
readonly getAuthorsCount = this.selector(
this.getAuthors,
authors => authors.length
);
}
One notable part that is missing compared to the ngrx/store are actions. Reducers and effects are called directly from Components. I think the removal of this indirection layer is fine, given that the state is local is not meant to be used throughout the Web App.
The entire implementation of ComponentStore
is fairly small and would likely be contained within a single file in under 200-250 Line of Code.
Let's break down each of the existing parts of the ComponentStore
:
ComponentStore
takes initial state as the only argument that is passed to its constructor, and it passes it to the BehaviorSubject
that is the reactive heart of the class.
// component_store.ts
constructor(initialState: T) {
this.state$ = new BehaviorSubject(initialState);
}
It is initialized from the business-logic focused Service, e.g. BookStore
in our example:
// book_store.ts
@Injectable()
export class BooksStore extends ComponentStore<BooksState> {
constructor() {
// 👇 initial state
super({books: [], authors: {}});
}
...
}
Once initialized, the state is available via getState()
or a top level selector (when just the projector function is used) - this is how getState()
itself is initialized for convenience.
Similar to @ngrx/store
, the state is NOT exposed for imperative read - this is done to promote reactive thinking.
export abstract class ComponentStore<T> implements OnDestroy {
readonly getState: () => Observable<T>;
private readonly state$: BehaviorSubject<T>;
constructor(defaultState: T) {
this.state$ = new BehaviorSubject(defaultState);
this.getState = this.selector(s => s);
}
One of the key objectives is that the local state is tied to the life-cycle of the components. When Component is destroyed we want the local state to be cleaned up as well and on the subscriptions be unsubscribed.
ComponentStore
implements OnDestroy
interface and provides destroy$
Observable that extending Service can listen to for takeUntil()
triggers.
export abstract class ComponentStore<T> implements OnDestroy {
private readonly state$: BehaviorSubject<T>;
// Should be used only in ngOnDestroy
private readonly destroySubject$ = new ReplaySubject<void>();
// Exposed to any extending Store to be used for the teardowns.
readonly destroy$ = this.destroySubject$.asObservable();
constructor(defaultState: T) {
this.state$ = new BehaviorSubject(defaultState);
...
}
/** Completes all relevant Observable streams. */
ngOnDestroy() {
this.state$.complete();
this.destroySubject$.next();
}
}
All selectors and effects are listening for destroy$
as well, and would unsubscribe with takeUntil(destroy$)
when it's fired.
The state itself is completed.
The state of the ComponentStore
can be changed only through updater
. There are two types of reducers:
Reducer takes new values imperitavely as well as reactively. (see APIs).
/**
* Creates a reducer.
*
* @param reducerFn A static reducer function that takes 2 parameters (the
* current state and an argument object) and returns a new instance of the
* state.
* @return A function that accepts one argument which is forwarded as the
* second argument to `reducerFn`. Everytime this function is called
* subscribers will be notified of the state change.
*/
updater(reducerFn: (state: T) => T): () => void;
updater<V>(
reducerFn: (state: T, value: V) => T
): (observableOrValue: V | Observable<V>) => Subscription;
updater<V>(
reducerFn: (state: T, value?: V) => T
): (observableOrValue?: V | Observable<V>) => Subscription {
return (observableOrValue?: V | Observable<V>): Subscription => {
const observable$: Observable<V> = isObservable(observableOrValue)
? observableOrValue
: of(observableOrValue);
return observable$
.pipe(
distinctUntilChanged(),
takeUntil(this.destroy$)
)
.subscribe(value =>
this.state$.next(reducerFn(this.state$.value, value))
);
};
}
Unlike @ngrx/store
, where Actions trigger Reducers and/or Effects, in ComponentStore
effects are triggered directly from Components, and if the state needs to be changed as part of the side-effect - these reducers are invoked within effects themselves.
Effect takes new values imperitavely as well as reactively. (see APIs).
/**
* This effect is subscribed to for the life of the @Component.
* @param generator A function that takes an origin Observable input and
* returns an Observable. The Observable that is returned will be
* subscribed to for the life of the component.
* @return A function that, when called, will trigger the origin Observable.
*/
effect<V, R = unknown>(
generator: (origin$: Observable<V>) => Observable<R>
): EffectReturnFn<V> {
const origin$ = new Subject<V>();
// 👇 Wrapped with retryable function, similar to ngrx/effect
generator(origin$)
// tied to the lifecycle 👇 of ComponentStore
.pipe(takeUntil(this.destroy$))
.subscribe();
return (observableOrValue?: V | Observable<V>): Subscription => {
const observable$ = isObservable(observableOrValue)
? observableOrValue
: of(observableOrValue);
return observable$.pipe(takeUntil(this.destroy$)).subscribe(value => {
// any new 👇 value is pushed into a stream
origin$.next(value);
});
};
}
Selectors are a way to read the data from ComponentStore.
Top level selector can access the state.
Selectors can also be combined with other Observable-providing sources (as well as other selectors), and their values are projected into the last argument of selector - a projector
function.
selector<R>(projector: (s: T) => R): () => Observable<R>;
selector<R, S1>(
s1: () => Observable<S1>,
projector: (s1: S1) => R
): () => Observable<R>;
selector<R, S1, S2>(
s1: () => Observable<S1>,
s2: () => Observable<S2>,
projector: (s1: S1, s2: S2) => R
): () => Observable<R>;
selector<R, S1, S2, S3>(
s1: () => Observable<S1>,
s2: () => Observable<S2>,
s3: () => Observable<S3>,
projector: (s1: S1, s2: S2, s3: S3) => R
): () => Observable<R>;
selector<R, S1, S2, S3, S4>(
s1: () => Observable<S1>,
s2: () => Observable<S2>,
s3: () => Observable<S3>,
s4: () => Observable<S4>,
projector: (s1: S1, s2: S2, s3: S3, s4: S4) => R
): () => Observable<R>;
selector<R>(...args: any[]): () => Observable<R> {
let observable$: Observable<R>;
const projector: (...args: any[]) => R = args.pop();
if (!args.length) {
// If there's only one argument, it's just a map function.
observable$ = this.state$.pipe(map(projector));
} else {
// If there are multiple arguments, we're chaining selectors, so we need
// to take the combineLatest of them before calling the map function.
observable$ = combineLatest(args.map(a => a())).pipe(
map((args: any[]) => projector(...args))
);
}
const distinctSharedObservable$ = observable$.pipe(
distinctUntilChanged(),
shareReplay({
refCount: true,
bufferSize: 1,
}),
takeUntil(this.destroy$),
);
return () => distinctSharedObservable$;
}
Selectors are making sure the same values are not emitted with distictUntilChanged()
operator.
They are also making sure that the execution context is reused with shareReplay()
operator. That means if the same selector is used in multiple places they will be sharing the results instead of each of them triggering a new chain of Observables.
Unlike selectors in @ngrx/store
, here they take Observables (or actually functions that return Observables), that means that selectors can combine data not only from ComponentStore
but from any other Observable producing source as well.
So, it is possible to combine local state with other Observables, for example with store.select(selectFromGlobalState)
:
readonly getLastAuthorWithContent = this.selector(
// 👇 local state selector
this.getAuthors,
// 👇 another local state selector
this.getAuthorsCount,
// 👇 selector from Global Store (@ngrx/store)
() => this.store.select(storeContent),
(authors, authorsCount, content) => ({
...authors[authorsCount - 1],
content
})
);
Children Components can easily access the ComponentStore that is provided at the parent level (or higher), by simply injecting it. It works because of the Angular Injector hierarchy.
Note it shouldn't put it at the
providers
as well, as that would create a new instance of the service.
@Component({
selector: "book",
templateUrl: "book.component.html",
// child 👇 component should NOT provide the service
providers: []
})
export class BookComponent {
readonly authors$: Observable<Author[]> =
// reactive way to 👇 read all authors
this.booksStore.getAuthors();
// Injects the specialized ComponentStore
// that knows how to work with book data 👇
constructor(private readonly booksStore: BooksStore) {}
}
In the example above, BooksStore
is tied to the BookComponent
's parent component (BookShelfComponent
).
Should the child component need its own local state ComponentStore
, it's easy to create and provide at child component level.
Child's SingleBookStore
would be tied to its life-cycle.
@Component({
selector: "book",
templateUrl: "book.ng.html",
// child's own 👇 component store
providers: [SingleBookStore]
})
export class BookComponent {
// Has access to both parent's BooksStore
// and its own SingleBookStore 👇
constructor(
private readonly booksStore: BooksStore,
private readonly singleBookStore: SingleBookStore) {}
}
// TODO: describe how
String selectors do not make a lot of sense, as it's quite easy to provide a mapping/projector function. On top of that, string selectors do not work well with advanced code optimizers that do "property renaming".
Selectors have:
distinctUntilChanged()
referential comparison, which prevents same values to be passed for selector consumers.shareReplay({refCount: true, bufferSize: 1})
on top of that, so if the same selector is used in multiple places - they all share the same execution context and not trigger the same operations multiple times.Currently, selectors are not memoized.
Which package would be best for this library? Should we add it to @ngrx/component
or create new @ngrx/component-store
.
Initially I was more inclined to add to @ngrx/component
, but I think it might cause some confusion, since it's not related to any of ngrxPush
, ngrxLet
or any of the coalesing stuff.
Since this library is completely self-contained, I suggest to push it to the new @ngrx/component-store
library. It would also help with the Docs.
Let's decided on the naming of the methods. There are multiple ways we can go about it:
Have a naming that's close to what NgRx/Redux currently has (reducer
/selector
/effect
).
Pros:
Cons:
@ngrx/store
are dependent on each other (they are completely independent).Naming that's exactly the same as what NgRx has (createReducer
/createSelector
/createEffect
).
Pros:
Cons:
à la XState naming (updater
/reader
/asyncUpdater
)
Pros:
Cons:
asyncUpdater
does not describe well what it's doing or what the intentions are.Hybrid naming (updater
/selector
/effect
or updater
/reader
/effect
)
Pros:
reducer
that some new users find scary or intimidating.@ngrx/store
.Cons:
Any other???
Selectors do we want to have them to be () => Observable
or just Observable
?
Currently they are () => Observable
for two reasons:
undefined
.Do we want to have selectors memoized? (lower priority)
If we do, that would significantly increase the size of ComponentStore
.
Should we add to getState
imperatively? It won't be coming from selectors (they are Observable) and might promote non-reactive bad practices.
Should we add Lazy init? e.g. hold off selector emissions until explicit first setState()
call? Would reqiure a bit of rewiring and ReplaySubject
instead of BehaviorSubject
as the state container.
Should we add a reactive way to push data into ComponentStore
? (similar to connect
from RxState
)
It might be a good feature.
Implemenented
I suggest putting this abstract class under the @ngrx/component
library, as it targets Component
improvements.
If it's not used, it would be tree-shaken out of the code.
New API
Suggest to rollout as alpha, and maybe include in v10.
Maintained by NgRx team.
Drawbacks:
Benefits:
RxState's setState
acts like a updater
, as it operates on the state itself, setting the values. Imperatively setting values is also possible in RxState by wrapping it with a function.
RxState has another way to set values - via .connect
ing Observables to the state. It could be useful when we want to set the state based on Observable emissions and a similar method could be added to the ComponentStore
API.
(Update: such API was added)
hold
is a way RxState is working with side-effects. It takes an Observable as an input and can trigger side-effects.
effect
, however, can also take values imperatively and pipe it to the Subject that's within the effect. That helps when values are pushed to the effect from Component's callbacks (e.g. when the User clicks a button to save an input).
select
takes a string or an RxJS operator. I'm yet to figure out how operator helps more than a simple function that selector
takes. map
operator is used within a selector itself.
Overall, I find RxState's APIs are not as convenient as ComponentStore
's.