changed 4 years ago
Linked with GitHub

ComponentStore for @ngrx Design Doc

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

Objective

To have an alternative to a "Service with BehaviorSubject" for state management solution that:

  • Has the data/state tied to a Component life-cycle (for setup and tear down),
  • Allows complex components to be easily shareable across pages,
  • Allows to have multiple instances of components that require state management,
  • Is reactive (push-based).

Approvals

NgRx Team - Tim, Brandon, Wes and Mike

Expected User/Developer Experience

"Module state" example

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.

"Multiple instances of a complex component" example

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 BookComponents.
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.

"Large shareable component" example

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".

Developer Experience

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

Background

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

Goals

  • Can be used without ngrx/store
  • helps component to clear its local state
  • is reactive/push-based solution
  • is type-safe
  • no "magic strings" that assume presence of any properties
  • keeps state immutable
  • makes it performant
  • keeps testing simple

NoGoals:

  • Does not try to keep the data in a single storage, as it's meant for local state.
  • is not meant to be replacement for Global Store (ngrx/store)
  • is not meant to be tied to app-level component (which would effectively be Global Store)
  • does not need to be attached to Redux dev tools

Prototype

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:

  • reducer/updater - takes current state and applies new changes, returning immutable new state of the same shape.
  • effect - side-effects, that typically trigger async events, such as network calls.
  • selector - selects, transforms and composes data from ComponentStore's state into something that Component can easily consume.

APIs

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 by ComponentStore.

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

Minimal Design

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

Detailed Design

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:

Initial State

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: {}});
  }
  ...
}

ComponentStore State

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

OnDestroy life-cycle

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.

updater

The state of the ComponentStore can be changed only through updater. There are two types of reducers:

  • Private reducers - meant to be used only within the specific Store, e.g. within Effects.
  • Public reducers - meant to be used by Components and also could be used by Effects.

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

effect

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

selector

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.

Combining with data from Store or other Observable sources

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 accessing ComponentStore

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

Multiple levels of ComponentStores

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

Ability to pass the initial state from Component

// TODO: describe how

Caveats

String selectors

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".

Performance Considerations

Selectors have:

  1. distinctUntilChanged() referential comparison, which prevents same values to be passed for selector consumers.
  2. 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.

Open Questions

  1. 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.

  2. 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:

      • It would be familar to people who already use NgRx.
      • When combining with Global store - it might be more intuitive.

      Cons:

      • Might cause confusion that this library and @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:

      • Very familar to NgRx users.

      Cons:

      • Might cause confusion, wrong imports and etc.
    • à la XState naming (updater/reader/asyncUpdater)
      Pros:

      • Clear distinction that it's a separate library
      • Might be easer for new users

      Cons:

      • A bit more akward integration if used with Global Store
      • asyncUpdater does not describe well what it's doing or what the intentions are.
    • Hybrid naming (updater/selector/effect or updater/reader/effect)
      Pros:

      • Mostly removes reducer that some new users find scary or intimidating.
      • Clearer names that might be even more intuitive to both new users and those who are familiar with NgRx.
      • Clear separation from @ngrx/store.

      Cons:

      • Cannot find any?
    • Any other???

  3. Selectors do we want to have them to be () => Observable or just Observable?
    Currently they are () => Observable for two reasons:

    • A bit easier to mock for tests
    • If used in templates and mistyped they would throw an error instead of being undefined.
  4. Do we want to have selectors memoized? (lower priority)
    If we do, that would significantly increase the size of ComponentStore.

  5. Should we add to getState imperatively? It won't be coming from selectors (they are Observable) and might promote non-reactive bad practices.

  6. 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.

  7. Should we add a reactive way to push data into ComponentStore? (similar to connect from RxState)
    It might be a good feature.

    Implemenented

Developer Impact

Public API Surface Impact

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.

Breaking Changes

New API

Rollout Plan

Suggest to rollout as alpha, and maybe include in v10.

Maintenance Plan

Maintained by NgRx team.

Alternatives considered

Solution that is kept as part of the global store.

Drawbacks:

  • Cannot be used as a standalone and would require @ngrx/store to be included as well.
  • Would be significantly larger.
  • Would require additional magic and complexity when actions are dispatched and in effects, to distinguish for which particular instance the action was fired.
  • Would keep an extra indirection layer (actions) for something that should be fine without it.

Benefits:

  • Would be visible in Redux DevTools.
  • Part of the Global Store.

RxState linked here

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 .connecting 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.

Select a repo