or
or
By clicking below, you agree to our terms of service.
New to HackMD? Sign up
Syntax | Example | Reference | |
---|---|---|---|
# Header | Header | 基本排版 | |
- Unordered List |
|
||
1. Ordered List |
|
||
- [ ] Todo List |
|
||
> Blockquote | Blockquote |
||
**Bold font** | Bold font | ||
*Italics font* | Italics font | ||
~~Strikethrough~~ | |||
19^th^ | 19th | ||
H~2~O | H2O | ||
++Inserted text++ | Inserted text | ||
==Marked text== | Marked text | ||
[link text](https:// "title") | Link | ||
 | Image | ||
`Code` | Code |
在筆記中貼入程式碼 | |
```javascript var i = 0; ``` |
|
||
:smile: | ![]() |
Emoji list | |
{%youtube youtube_id %} | Externals | ||
$L^aT_eX$ | LaTeX | ||
:::info This is a alert area. ::: |
This is a alert area. |
On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?
Please give us some advice and help us improve HackMD.
Do you want to remove this version name and description?
Syncing
xxxxxxxxxx
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:
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 forComponentStore
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 individualBookComponent
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."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 hisproviders
, 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.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 (typicallyBehaviorSubject
).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
akaStateService
akaRxState
.Goals/NoGoals
Goals
NoGoals:
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:
ComponentStore
's state into something thatComponent
can easily consume.APIs
❗️APIs for
ComponentStore
allow to read the state throughObservable
and to update the state either reactively (throughObservable
) 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 bereducer
orupdater
) - 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). Typicallyupdater
would be in a Sevice together with business logic, and(newValue)
is called in a Component. e.g.:updater((state, newValue) => ({...state, value: newValue}))(newValueObservable$)
update state using values from
an Observable
. Typicallyupdater
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.selector
(alertnative namesselector
orreader
) - 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 wheneverstate.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), wheneverselector1
orselector2
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), wheneverselector1
orselector2
Observables orobservable$
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 nameseffect
orasyncUpdater
) - orchestrates a side-effect, that can handle async operations, such as network calls. Unsubscribes side-effects whenComponentStore
is destoyed (or a Component that is attached to it) OR when a returnedSubscription
is explicitely unsubscribed.effect(() => observable$)()
Creates a side-effect that would be unsubscribed/destroyed only when
ComponentStore
is destroyed.effect(value$ => value$.pipe(...))(newValue);
Creates a side-effect that receives new values imperatively by passing
newValue
. EachnewValue
is passed intovalues$
observable.Unsubscribes when
ComponentStore
is destroyed.effect(value$ => value$.pipe(...))(observable$);
Creates a side-effect that receives new values emitted by
observable$
. Each new value is passed intovalues$
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 particularobservable$
emissions when returned Subscription is unsubscribed.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.The Business Logic would be in the Services that extend
ComponentStore
, for exampleBookStore
, and it would use these methods to control flow/state of the data.This is what the Developer might implement:
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 theBehaviorSubject
that is the reactive heart of the class.It is initialized from the business-logic focused Service, e.g.
BookStore
in our example:ComponentStore State
Once initialized, the state is available via
getState()
or a top level selector (when just the projector function is used) - this is howgetState()
itself is initialized for convenience.Similar to
@ngrx/store
, the state is NOT exposed for imperative read - this is done to promote reactive thinking.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
implementsOnDestroy
interface and providesdestroy$
Observable that extending Service can listen to fortakeUntil()
triggers.All selectors and effects are listening for
destroy$
as well, and would unsubscribe withtakeUntil(destroy$)
when it's fired.The state itself is completed.
updater
The state of the
ComponentStore
can be changed only throughupdater
. There are two types of reducers:Reducer takes new values imperitavely as well as reactively. (see APIs).
effect
Unlike
@ngrx/store
, where Actions trigger Reducers and/or Effects, inComponentStore
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).
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.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 fromComponentStore
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)
: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.
In the example above,
BooksStore
is tied to theBookComponent
'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.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:
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.
Open Questions
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 ofngrxPush
,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
orupdater
/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 justObservable
?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 andReplaySubject
instead ofBehaviorSubject
as the state container.Should we add a reactive way to push data intoComponentStore
? (similar toconnect
fromRxState
)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 targetsComponent
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:
Benefits:
RxState linked here
RxState's
setState
acts like aupdater
, 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 theComponentStore
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 thatselector
takes.map
operator is used within a selector itself.Overall, I find RxState's APIs are not as convenient as
ComponentStore
's.