├── .gitignore ├── .idea ├── .gitignore ├── blog-crafting-reactive-ephemeral-state.iml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── README.md ├── angular.json ├── browserslist ├── images ├── angular-timeline__michael-hladky.png ├── cover-reactive-local-state__michael-hladky.png ├── reactive-local-changes_processing-global-sources__michael-hladky.png ├── reactive-local-changes_processing-local-sources__michael-hladky.png ├── reactive-local-quote-gang-of-four2__michael-hladky.png ├── reactive-local-quote-gang-of-four__michael-hladky.png ├── reactive-local-state-declarative-interaction-breaking-flow__michael-hladky.png ├── reactive-local-state-declarative-interaction-connector-and-state__michael-hladky.png ├── reactive-local-state-declarative-interaction-connector-code__michael-hladky.png ├── reactive-local-state-declarative-interaction-connector__michael-hladky.png ├── reactive-local-state-declarative-interaction-setter__michael-hladky.png ├── reactive-local-state-first-draft__michael-hladky.png ├── reactive-local-state-hot-cold_unicast-multicast.png ├── reactive-local-state-intro__michael-hladky.png ├── reactive-local-state-sate-late-subscriber__michael-hladky.png ├── reactive-local-state-sate-subscriber-problem__michael-hladky.png ├── reactive-local-state-sate-subscriber-replay-caveat-cold-composition__michael-hladky.png ├── reactive-local-state-sate-subscriber-replay-caveat-workload__michael-hladky.png ├── reactive-local-state-sate-subscriber-replay-cold-composition-problem__michael-hladky.png ├── reactive-local-state-sate-subscriber-replay-cold-composition-solution__michael-hladky.png ├── reactive-local-state-sate-subscriber-solution__michael-hladky.png ├── reactive-local-state_ephemeral-state__michael-hladky.png ├── reactive-local-state_global-accessible__michael-hladky.png ├── reactive-local-state_layers-of-state__michael-hladky.png ├── reactive-local-state_lifetime-angular-building-blocks__michael-hladky.png ├── reactive-local-state_lifetime-async-pipe__michael-hladky.png ├── reactive-local-state_lifetime-global-singleton-service__michael-hladky.png ├── reactive-local-state_local-accessible__michael-hladky.png ├── reactive-local-state_subscription-handling__michael-hladky.png ├── reactive-local-state_timing-component-lifecycle__michael-hladky.png ├── reactive-local-state_timing-lifecycl-hooks-and-subscriptions-hello-world__michael-hladky.png ├── reactive-local-state_uni-case-vs-multi-cast-instance__michael-hladky.png ├── reactive-local-state_uni-case-vs-multi-cast-observables__michael-hladky.png ├── reactive-local-state_uni-case-vs-multi-cast-operators__michael-hladky.png ├── reactive-local-state_uni-case-vs-multi-cast-work__michael-hladky.png ├── reactive-local-state_uni-case-vs-multi-cast__michael-hladky.png └── researc-ephemeral-state-dramatic-title.png ├── package-lock.json ├── package.json ├── src ├── app │ ├── app-component │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.ts │ │ └── app.view.model.ts │ ├── app.module.ts │ ├── app.routes.ts │ ├── common │ │ ├── component-state.service.ts │ │ ├── index.ts │ │ ├── local-effects.service.ts │ │ └── local-state.service.ts │ ├── data-access │ │ └── github │ │ │ ├── +state │ │ │ ├── actions.ts │ │ │ ├── effects.ts │ │ │ ├── reducer.ts │ │ │ ├── repository-list.model.ts │ │ │ └── selectors.ts │ │ │ ├── github.module.ts │ │ │ ├── github.service.ts │ │ │ └── index.ts │ └── examples │ │ ├── demo-basics │ │ ├── 1 │ │ │ └── demo-basics-1.component.ts │ │ ├── 2 │ │ │ └── demo-basics-2.component.ts │ │ ├── 3 │ │ │ └── demo-basics-3.component.ts │ │ ├── 4 │ │ │ ├── demo-basics-4.component.ts │ │ │ ├── demo-basics-4.view.html │ │ │ ├── demo-basics.base-model.interface.ts │ │ │ ├── demo-basics.view-model.service.ts │ │ │ └── demo-basics.view.interface.ts │ │ ├── demo-basics-item.interface.ts │ │ ├── demo-basics.container.component.ts │ │ ├── demo-basics.module.ts │ │ └── rx-ephemeral-state.ts │ │ ├── examples.container.component.ts │ │ └── problems │ │ ├── cold-composition │ │ ├── cold-composition-bad.component.ts │ │ ├── cold-composition-good.component.ts │ │ ├── cold-composition.container.component.ts │ │ ├── cold-composition.module.ts │ │ ├── some-bad.service.ts │ │ └── some-good.service.ts │ │ ├── declarative-interaction │ │ ├── declarative-interaction-bad.component.ts │ │ ├── declarative-interaction-bad.service.ts │ │ ├── declarative-interaction-good.component.ts │ │ ├── declarative-interaction-good.service.ts │ │ ├── declarative-interaction.container.component.ts │ │ ├── declarative-interaction.module.ts │ │ ├── declarative-side-effects-good.component.ts │ │ └── declarative-side-effects-good.service.ts │ │ ├── late-subscriber │ │ ├── late-subscriber-fix.display.component.ts │ │ ├── late-subscriber.container.component.ts │ │ ├── late-subscriber.display.component.ts │ │ └── late-subscriber.module.ts │ │ ├── sharing-a-reference │ │ ├── sharing-a-reference-bad.display.component.ts │ │ ├── sharing-a-reference-basics.display.component.ts │ │ ├── sharing-a-reference-good.display.component.ts │ │ ├── sharing-a-reference-imp.display.component.ts │ │ ├── sharing-a-reference.container.component.ts │ │ └── sharing-a-reference.module.ts │ │ └── subscription-handling │ │ ├── subscription-handling-bad.component.ts │ │ ├── subscription-handling.component.ts │ │ ├── subscription-handling.module.ts │ │ └── subscription-handling.service.ts ├── assets │ ├── .gitkeep │ └── research-ephemeral-state-dramatic-title.png ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── styes │ ├── _general.scss │ ├── _logger.scss │ └── theme.scss └── styles.scss ├── tsconfig.app.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | 4 | # compiled output 5 | /dist 6 | /tmp 7 | /out-tsc 8 | # Only exists if Bazel was run 9 | /bazel-out 10 | 11 | # dependencies 12 | /node_modules 13 | 14 | # profiling files 15 | chrome-profiler-events*.json 16 | speed-measure-plugin*.json 17 | 18 | # IDEs and editors 19 | .idea 20 | .project 21 | .classpath 22 | .c9/ 23 | *.launch 24 | .settings/ 25 | *.sublime-workspace 26 | 27 | # IDE - VSCode 28 | .vscode/* 29 | !.vscode/settings.json 30 | !.vscode/tasks.json 31 | !.vscode/launch.json 32 | !.vscode/extensions.json 33 | .history/* 34 | 35 | # misc 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Default ignored files 3 | /workspace.xml -------------------------------------------------------------------------------- /.idea/blog-crafting-reactive-ephemeral-state.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Studies are done with Angular as an example for a component-oriented framework and RxJS is used as an example for a reactive programming library 2 | 3 | --- 4 | As this is too much text I'm afraid the important things at the end will get lost, 5 | I put it here and quote one of Richard Feynman's rules he stuck to when teaching: 6 | 7 | > Give credit where it's due 8 | _Richard Feynman_ 9 | 10 | - [@ngrx_io](https://twitter.com/ngrx_io) - that listened to my questions and gave me useful feedback 11 | - [@yjaaidi](http://twitter.com/yjaaidi) and [@niklas_wortmann](https://twitter.com/niklas_wortmann) - may be the only 2 persons on earth that read ALL THAT 12 | - [@juristr](https://twitter.com/juristr) - that pretended he will use it in his projects 13 | - [@mvmusatov](https://twitter.com/mvmusatov) - that sent a [PR](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/pull/1) and fixed my messy demo :D 14 | 15 | --- 16 | 17 | In most of the component-oriented applications, there is the need to structure container components. 18 | Even after a well thought refactoring into more display components and grouping logic into responsibilities it's always hard to handle. 19 | 20 | The data structure you manage inside these components is only here for their very component. Not for any other components. 21 | This data structure appears with the component and disappears when the component is removed. 22 | 23 | This is a good example of an ephemeral state. 24 | 25 | If you have a well thought and structured approach on how to manage ephemeral state such components get a breeze to write. 26 | You could master a fully reactive architecture in a scalable and maintainable way. 27 | 28 | This article provides you with some fundamental information about my findings in reactive ephemeral state management. 29 | It is applicable to every framework that is component-oriented and has some life cycle hooks for creation and destruction. 30 | 31 | The below examples are done with Angular as a framework as it has DI built-in which comes in handy here. 32 | As a Reactive programming library with cold Observables by default, I picked RxJS as it is well supported. 33 | 34 | --- 35 | 36 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-intro__michael-hladky.png "How to Avoid Observables in Angular - Intro") 37 | 38 | --- 39 | 40 | # Table of Content 41 | 42 | 43 | 44 | - [TL;DR](#tldr) 45 | - [Methodology](#methodology) 46 | - [Layers of State](#layers-of-state) 47 | * [What is the ephemeral state?](#what-is-the-ephemeral-state) 48 | + [Global vs Local Accessibility of Data Structures](#global-vs-local-accessibility-of-data-structures) 49 | + [Static vs Dynamic Lifetime of Data Structures](#static-vs-dynamic-lifetime-of-data-structures) 50 | + [Global vs Local Processed Sources](#global-vs-local-processed-sources) 51 | + [Recap Ephemeral State](#recap-ephemeral-state) 52 | - [Problems to Solve on a Low-Level](#problems-to-solve-on-a-low-level) 53 | * [Timing](#timing) 54 | * [Subscription Handling](#subscription-handling) 55 | * [Sharing State and State Derivations](#sharing-state-and-state-derivations) 56 | + [Uni and multi-casting with RxJS](#uni-and-multi-casting-with-rxjs) 57 | + [Sharing Work](#sharing-work) 58 | + [Sharing Instances](#sharing-instances) 59 | * [The Late Subscriber Problem](#the-late-subscriber-problem) 60 | + [Cold Composition](#cold-composition) 61 | * [Subscription-Less Interaction with Component StateManagement](#subscription-less-interaction-with-component-statemanagement) 62 | + [Subscription-Less Handling of Side-Effects](#subscription-less-handling-of-side-effects) 63 | * [Recap Problems](#recap-problems) 64 | - [Basic Usage](#basic-usage) 65 | * [Service Design](#service-design) 66 | * [Service Usage](#service-usage) 67 | - [Summary](#summary) 68 | 69 | 70 | 71 | --- 72 | 73 | # TL;DR 74 | 75 | If you are into reactive programming you will learn about some cool topics. 76 | - unicast vs. multicast 77 | - hot vs. cold 78 | - subscription-less components 79 | - higher-order operators like [mergeAll](https://rxjs.dev/api/operators/mergeAll) 80 | 81 | If you are also into ephemeral state management you can learn how to detect it: 82 | 83 | **We defined 3 rules of thumb to detect ephemeral state** 84 | - No horizontal sharing of state 85 | - The lifetime of the state is dynamic 86 | - It processes local relevant events 87 | 88 | Your understanding of the fundamental implementation will get a good boost! 89 | Examples on how to introduce different architecture patterns in your component structure are only the tip of the iceberg: 90 | - Initiation and coupling state to e.g. a component 91 | - Interaction 92 | - derivation of state 93 | 94 | are just some of the nitty-gritty details included! 95 | 96 | Here the Important resources: 97 | - **Recording** ([🎥 Live Demo at 24:47](https://www.youtube.com/watch?v=I8uaHMs8rw0&t=24m47s)): 98 | {% youtube I8uaHMs8rw0 %} 99 | - **Repository For Examples** ([💾 Final Example](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/tree/master/src/app/examples/demo-basics)): 100 | {% github BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks no-readme %} 101 | - **Sourcecode**: [📦 ngx-rx/rxjs-state](https://github.com/BioPhoton/ngx-rx/tree/master/libs/rxjs-state) 102 | - **NPM Package**: [📦 ngx-rx-state](https://github.com/BioPhoton/ngx-rx/tree/master/libs/ngx-rx-state) 103 | 104 | --- 105 | 106 | # Methodology 107 | 108 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-quote-gang-of-four__michael-hladky.png "Gang of four quote") 109 | 110 | If you go back in history you will find almost all our nowadays "cutting edge problems" already solved. 111 | When I realized the first time that life is a "constant evolutionary repetition" I started to change my strategy on solving problems. 112 | 113 | Before I almost always started to implement a half-backed cool idea which I was certain is most up to date with technologies. 114 | 115 | After I made many mistakes (without them I would not be here today) and got some incredibly helpful insights, I now started to change my way of solving problems completely. 116 | Let me quote **The Gang Of Four** to give you the first glimpse of my fundamental changes in how I approach problems: 117 | 118 | So here is what the gang of four says about Object-Oriented-Software-Design-Patterns: 119 | 120 | > If you stick to the paradigms of object-oriented programming, 121 | > the design patterns appear naturally. 122 | 123 | With that in mind, I did not work on a solution for ephemeral state management, 124 | but tried to look at all the different problems that we will face when managing any local state in general, with the hope, in the end, a solid solution will appear naturally. 125 | 126 | Let's start with the first chapter and some general information to get you on track. 127 | 128 | # Layers of State 129 | 130 | Of course, there are WAY more, but in this article, I will introduce 3 layers of state: 131 | - (Persistent) Server State 132 | - Persistent Client State (Global State) 133 | - Ephemeral Client State (Local State) 134 | 135 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_layers-of-state__michael-hladky.png "Layers of State") 136 | 137 | **Persistent Server State** is the data in your database. It is provided to the consumer over a data API like REST, GraphQL, Websocket, etc. 138 | This is very different from **Meta State**, which is information related to the status of a resource that provides us a state. E.g. Loading, Error, Success, etc. 139 | 140 | For persistent and ephemeral client states I will try to use to more simpler wording. 141 | I will use **Global State** for persistent client state and **Local State** of the ephemeral client state. 142 | Both live on the client, but they demand a completely different way of treatment. 143 | 144 | In this article, I want to focus on the ephemeral state. 145 | 146 | ## What is the ephemeral state? 147 | 148 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_ephemeral-state__michael-hladky.png "What is Ephemeral State") 149 | 150 | The ephemeral state is just one of many names for data structures 151 | that needed to be managed on the client under special conditions. 152 | Other synonyms are a transient state, UI state, local state, component state, short term data, etc... 153 | 154 | It is the data structure that expresses the state 155 | of an isolated unit like for example a component in your application. 156 | 157 | As the word "isolated" is a bit vague, let me get a little bit more concrete. 158 | 159 | It's the state that lives in your components, pipes, directives and some of the services that are created 160 | and destroyed over time. The state is not shared between siblings and not populated to global services. 161 | 162 | ### Global vs Local Accessibility of Data Structures 163 | 164 | The term global state is well known in modern web development. 165 | It is the state we share globally in our app e.g. a `@ngRx/store` or the good old `window` object ;) 166 | 167 | This is in this article called persistent state. 168 | 169 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_global-accessible__michael-hladky.png "Global Accessible State") 170 | 171 | As we can see one global source distributes state to the whole app. 172 | 173 | If we compare this to a local state we see that this data structure is provided and managed only in a certain time-frame of your app. 174 | For example in a component or directive. 175 | 176 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_local-accessible__michael-hladky.png "Local Accessible State") 177 | 178 | This is our first rule of thumb to detect local state: 179 | 180 | > No horizontal sharing of the state e.g. with sibling components or upwards. 181 | 182 | ### Static vs Dynamic Lifetime of Data Structures 183 | 184 | In Angular global state is nearly always shared over global singleton services. 185 | Their lifetime starts even before the root component. And ends after every child component. 186 | The state's lifetime is ~equal to the Apps lifetime or the browser windows lifetime. 187 | 188 | This is called a static lifetime. 189 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_lifetime-global-singleton-service__michael-hladky.png "Lifetime Global Singleton Service") 190 | 191 | 192 | If we compare this to the lifetime of other building blocks of Angular we can see their lifetime is way more dynamic. 193 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_lifetime-angular-building-blocks__michael-hladky.png "Lifetime Angular Building Blocks") 194 | 195 | State in this building blocks is tied to the lifetime of their owners, their hosts and if shared this state is shared then only with children. 196 | 197 | The best example of a dynamic lifetime is data that gets rendered over the `async` pipe. 198 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_lifetime-async-pipe__michael-hladky.png "Lifetime async Pipe") 199 | 200 | The lifetime depends on the evaluation of the template expression, a potential `*ngIf` that wraps the expression or e.g a directive. 201 | 202 | For our second rule of thumb we detected for the local state is: 203 | 204 | > The lifetime is dynamic e.g. bound to the lifetime of a component or an async pipe 205 | 206 | ### Global vs Local Processed Sources 207 | 208 | Where our global state service nearly always processes remote sources: 209 | - REST API's HTTP, HTTP2 210 | - Web Sockets 211 | - Browser URL 212 | - Browser Plugins 213 | - Global Static Data 214 | - The `window` object 215 | 216 | And the logic is located in the more abstract layers of our architecture. 217 | 218 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-changes_processing-global-sources__michael-hladky.png "Processing of Global Sources") 219 | 220 | Code dedicated to the local state would nearly always focus on the process of the following sources: 221 | - Data from `@InputBindings` 222 | - UI Events 223 | - Component level Side-Effects 224 | - Parsing global state to local 225 | 226 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-changes_processing-local-sources__michael-hladky.png "Processing of Local Sources") 227 | 228 | The third rule of thumb to detect local state is: 229 | 230 | > It processes mostly local sources e.g. sort/filter change 231 | 232 | --- 233 | 234 | ### Recap Ephemeral State 235 | 236 | > **We defined 3 rules of thumb to detect ephemeral/local state** 237 | > - No horizontal sharing of state 238 | > - The lifetime of the state is dynamic 239 | > - It processes local relevant events 240 | 241 | Some real-life example that matches the above-defined rules are: 242 | - sorting state of a list 243 | - form errors 244 | - state of an admin panel (filter, open/close, ...) 245 | - any dynamic appearing data 246 | - accumulations from @Input data 247 | - extended global state or derived global state for a container component 248 | 249 | You rarely share this data with sibling components, it only shares data-structures only locally and focuses mostly on local sources. 250 | In other words, there is no need to use many of the concepts of global state management libraries e.g. actions. 251 | 252 | Still, we need a way to manage these data structures. 253 | 254 | # Problems to Solve on a Low-Level 255 | 256 | As a first and foundational decision fact, we have to know we work with a push-based architecture. This has several advantages but more important defines the problems we will run into when implementing a solution. 257 | 258 | As we defined the way how we want to distribute our data let me list a set of problems we need to solve. 259 | 260 | ## Timing 261 | 262 | As a lot of problems I ran into in applications are related to timing issues, 263 | this section is here to give a quick overview of all the different things to consider. 264 | 265 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_timing-component-lifecycle__michael-hladky.png "Component Life Cycle Hooks") 266 | 267 | **Shouldn't reactive code be by design in a way that timing of certain things becomes irrelevant?** 268 | 269 | I mean, not that there is no time in observables, or that it does not matter when we subscribe to something, 270 | but when we compose observables we should not care about when any of our state sources exactly emit a value... 271 | 272 | In a perfect reactive setup, we don't need to care about those problems. 273 | However, as Angular is an object-orientated framework we often have to deal with different problems 274 | related to life-cycles of components and services, router-events and many more things. 275 | 276 | In RxJS timing is given by the following: 277 | - For hot observables the **time of creation** 278 | - For cold observables the **time of subscription** 279 | - For emitted values the **scheduling process** 280 | 281 | In Angular timing is given by the following: 282 | - For global services the **creation** as well as the application **lifetime** 283 | - For components the **creation**, several **life-cycle hooks** as well as the component **lifetime** 284 | - For local services the **creation** of the component as well as the components **lifetime** 285 | - For pipes or directives in the template also the components **lifetime** 286 | 287 | All timing relates things in Angular are in an object-oriented style, very similar to hot observables. 288 | Subscription handling can be done declaratively over completion operators. 289 | The scheduling process can be controlled both over imperative or over operators and can influence the execution context of the next error or complete callback. 290 | 291 | We see that there are two different concepts combined that have completely different ways of dealing with timing. 292 | Angular already solved parts of this friction points but some of 293 | them are still left and we have to find the right spots to put our glue code and fix the problem. 294 | 295 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/angular-timeline__michael-hladky.png "Angular Timeline") 296 | 297 | This chart shows a minimal Angular app with the different building units ant their timing: 298 | In this example it marks: 299 | - the global store lifetime 300 | - the component store lifetime 301 | - the async pipe lifetime 302 | 303 | As we can see It makes a big difference where we place observables and where we subscribe to them. 304 | It also shows where we need hot observables and where we need to replay values. 305 | 306 | ## Subscription Handling 307 | 308 | Let's discuss where subscriptions should take place and for which reason they are made. 309 | 310 | Subscriptions are here to receive values from any source, cold or hot. 311 | 312 | In most cases, we want to render incoming values to the DOM. 313 | 314 | For this reason, we use a `Pipe` or a `Directive` in the template to trigger 315 | change-detection whenever a value arrives. 316 | 317 | The other reason could be to run some background tasks in a `Component`, `Directive` or `Service`, 318 | which should not get rendered. E.g. a request to the server every 30 seconds. 319 | 320 | As subscriptions in the `Pipe` or `Directive` are handled over their life-cycle 321 | hooks automatically, we only have to discuss the scenarios for side-effects. 322 | 323 | Let's take a quick look to the diagram from before: 324 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_timing-component-lifecycle__michael-hladky.png "Life cycle hooks component") 325 | 326 | So what could be a good strategy related to the timing of subscriptions and their termination? 327 | 328 | One way to solve it would be to subscribe as early as possible and unsubscribe as late as possible. 329 | 330 | On a diagram it would look like that: 331 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_subscription-handling__michael-hladky.png "Subscription Handling Strategy") 332 | 333 | 334 | ```typescript 335 | @Component({ 336 | ... 337 | }) 338 | export class SubscriptionHandlingComponent implements OnDestroy { 339 | onDestroy$ = new Subject(); 340 | 341 | sideEffect$ = timer(0, 1000).pipe(tap(n => serverRequest(n))); 342 | 343 | constructor() { 344 | this.sideEffect$ 345 | .pipe(takeUntil(this.onDestroy$)) 346 | .subscribe(); 347 | } 348 | 349 | ngOnDestroy(): void { 350 | this.onDestroy$.next(true); 351 | } 352 | } 353 | ``` 354 | _(used RxJS parts: [timer](https://rxjs.dev/api/function/timer), [tap](https://rxjs.dev/api/operator/tap), [takeUntil](https://rxjs.dev/api/operator/takeUntil), [Subject](https://rxjs.dev/api/class/Subject))_ 355 | 356 | We already have a declarative subscription handling. 357 | But this code could get moved somewhere else. We could use 358 | the local service that we most probably will need if we implement 359 | the final implementation for component state handling. 360 | 361 | **Service** 362 | ```typescript 363 | export class SubscriptionHandlingService implements OnDestroy { 364 | 365 | onDestroy$ = new Subject(); 366 | 367 | subscribe(o): void { 368 | o.pipe(takeUntil(this.onDestroy$)) 369 | .subscribe(); 370 | } 371 | 372 | ngOnDestroy(): void { 373 | this.onDestroy$.next(true); 374 | } 375 | 376 | } 377 | ``` 378 | 379 | **Component** 380 | ```typescript 381 | @Component({ 382 | ... 383 | providers: [SubscriptionHandlingService] 384 | }) 385 | export class SubscriptionHandlingComponent { 386 | sideEffect$ = timer(0, 1000) 387 | .pipe(tap(console.log)); 388 | 389 | constructor(private subHandler: SubscriptionHandlingService) { 390 | this.subHandler 391 | .subscribe(this.sideEffect$) 392 | } 393 | } 394 | ``` 395 | 396 | In this way, we get rid of thinking about subscriptions in the component at all. 397 | 398 | 399 | ## Sharing State and State Derivations 400 | 401 | In many cases, we want to subscribe to more than one place to some source and render its data. 402 | Even with such a simple operation as retrieving and displaying the data several things needs to be considered. 403 | 404 | The interesting parts here are the data structure and derivation logic. 405 | 406 | We will skip the data structure and focus on the things related to RxJS. 407 | The derivation of data. 408 | 409 | ### Uni and multi-casting with RxJS 410 | 411 | As we have multiple sources we calculate the data for every subscription separately. 412 | This is given by the default behavior of RxJS. It is uni-cased by default. 413 | 414 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_uni-case-vs-multi-cast__michael-hladky.png "Uni-Cast VS Multi-Cast") 415 | 416 | **Basic uni-cast examples:** 417 | ```typescript 418 | const timeStampObservable = new Observable((subscriber) => { 419 | console.log('create date object'); 420 | const dataObject = new Date(); 421 | subscriber.next(dataObject); 422 | }); 423 | 424 | timeStampObservable 425 | .subscribe(d => console.log('date: ', d)); 426 | timeStampObservable 427 | .subscribe(d => console.log('date: ', d)); 428 | 429 | // Result in console: 430 | // 431 | // create date object 432 | // date: Fri Dec 13 2042 00:00:00 433 | // create date object 434 | // date: Fri Dec 13 2042 00:00:01 435 | ``` 436 | _(used RxJS parts: [Observable](https://rxjs.dev/api/class/Observable))_ 437 | 438 | If we want to multicast the values we could do something like that: 439 | 440 | **Basic multi-cast examples:** 441 | ```typescript 442 | const timestamp object = new Subject(); 443 | 444 | timeStampSubject 445 | .subscribe(d => console.log('date: ', d)); 446 | timeStampSubject 447 | .subscribe(d => console.log('date: ', d)); 448 | 449 | console.log('create date object'); 450 | const dataObject = new Date(); 451 | timeStampSubject.next(dataObject); 452 | 453 | // Result in console: 454 | // 455 | // create date object 456 | // date: Fri Dec 13 2042 00:00:00 457 | // date: Fri Dec 13 2042 00:00:00 458 | ``` 459 | We see how we can share data, now let's take a look at operations: 460 | 461 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_uni-case-vs-multi-cast-operators__michael-hladky.png "Uni-Cast VS Multi-Cast With Operators") 462 | 463 | **Operators uni-cast examples:** 464 | ```typescript 465 | const timeStampSubject = new Subject(); 466 | const timeStampObservable = timeStampSubject.pipe( 467 | map((date: Date) => { 468 | console.log('transformation of ', date); 469 | return date.getTime(); 470 | }) 471 | ); 472 | 473 | timeStampObservable 474 | .subscribe(d => console.log('date: ', d)); 475 | timeStampObservable 476 | .subscribe(d => console.log('date: ', d)); 477 | 478 | console.log('create date object'); 479 | const dataObject = new Date(); 480 | timeStampSubject.next(dataObject); 481 | 482 | // create date object 483 | // transformation of Fri Dec 13 2042 00:00:00 484 | // date: 1576231670737 485 | // transformation of Fri Dec 13 2042 00:00:00 486 | // date: 1576231670737 487 | ``` 488 | _(used RxJS parts: [map](https://rxjs.dev/api/operator/map))_ 489 | 490 | You remember the subscriber function we saw in the first example with the observable? 491 | Operators internally maintain a similar logic. We apply an operator the inner subscriber functions are chained. 492 | This is the reason we see the log for the transformation 2 times. For every subscriber one time. 493 | 494 | There are also operators that help to add multi-casting in operator chains. 495 | 496 | **Operators multi-cast examples:** 497 | ```typescript 498 | const timeStampSubject = new Subject(); 499 | const timeStampObservable = timeStampSubject.pipe( 500 | map((date: Date) => { 501 | console.log('transformation of ', date); 502 | return date.getTime(); 503 | }), 504 | share() 505 | ); 506 | 507 | timeStampObservable 508 | .subscribe(d => console.log('date: ', d)); 509 | timeStampObservable 510 | .subscribe(d => console.log('date: ', d)); 511 | 512 | console.log('create date object'); 513 | const dataObject = new Date(); 514 | timeStampSubject.next(dataObject); 515 | 516 | // create date object 517 | // transformation of Fri Dec 13 2042 00:00:00 518 | // date: 1576231670737 519 | // date: 1576231670737 520 | ``` 521 | _(used RxJS parts: [share](https://rxjs.dev/api/operator/share))_ 522 | 523 | 524 | ### Sharing Work 525 | 526 | With this knowledge let's take a look at some examples: 527 | 528 | In our view, we could do some processing for incoming data. 529 | An example could be an array of items from an HTTP call. 530 | 531 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_uni-case-vs-multi-cast-work__michael-hladky.png "Uni-Cast VS Multi-Cast - Work") 532 | 533 | ```typescript 534 | @Component({ 535 | template: ` 536 | 537 | 538 | `, 539 | ... 540 | }) 541 | export class AnyComponent { 542 | httpResult$ = this.http.get(url) 543 | .pipe( 544 | map(this.mapServerToClientObject) 545 | ); 546 | } 547 | ``` 548 | 549 | the work that is done in `mapServerToClient` is executed once per subscription. 550 | In our example 2 times. Even if we change the HTML and use `ng-container` to maintain only one subscription in the template 551 | in the class could be multiple other subscriptions we can't solve in the template. 552 | 553 | To save work we need to share the subscription. 554 | 555 | ```typescript 556 | export class AnyComponent { 557 | httpResult$ = this.http.get(url) 558 | .pipe( 559 | map(this.mapServerToClientObject), 560 | shareReplay({refCount: true, bufferSize: 1}) 561 | ); 562 | } 563 | ``` 564 | _(used RxJS parts: [shareReplay](https://rxjs.dev/api/operator/shareReplay))_ 565 | 566 | Here we use `shareReplay` to cache the last value, replay it and share all notifications with multiple subscribers. 567 | 568 | ### Sharing Instances 569 | This is a rare case but important to know if you work fully reactive. 570 | 571 | To start this section let's discuss the components implementation details first. 572 | We focus on the component's outputs. 573 | 574 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state_uni-case-vs-multi-cast-instances__michael-hladky.png "Uni-Cast VS Multi-Cast - Instance") 575 | 576 | ```typescript 577 | @Component({ 578 | ... 579 | }) 580 | export class AnyComponent { 581 | @Output() compOutput = new EventEmitter() 582 | } 583 | ``` 584 | 585 | Let's take a closer look at the EventEmitter interface: 586 | `EventEmitter extends Subject` 587 | And `Subject` looks like this: 588 | `Subject extends Observable implements SubscriptionLike` 589 | The important part here is that we can pass everything that holds a `subscribe` method. 590 | 591 | Which means the following would work: 592 | 593 | ```typescript 594 | @Component({ 595 | ... 596 | }) 597 | export class AnyComponent { 598 | @Output() compOutput = interval(1000); 599 | } 600 | ``` 601 | _(used RxJS parts: [interval](https://rxjs.dev/api/function/interval))_ 602 | 603 | An observable for example provides a `subscribe` method. 604 | Therefore we can directly use it as a value stream for our `@Output` binding. 605 | 606 | Knowing that enables us to take some very nice and elegant shortcuts as well as reducing the amount of code we need to write. 607 | 608 | With this in mind let's focus on the original problem, sharing work and references. 609 | 610 | In this example, we receive a config object from the parent component 611 | turn it into a reactive form and 612 | emit changes from the form group created out of the config object. 613 | 614 | Every time we receive a new value from the input binding 615 | - we create a config object out of it 616 | - and use the `FormBuilder` service to create the new form. 617 | As output value, we have to provide something that holds a `subscribe` method. 618 | So we could use the form groups `value changes` to provide the forms changes directly as component output events. 619 | 620 | ```typescript 621 | @Component({ 622 | selector: 'sharing-a-reference', 623 | template: ` 624 |
625 |
626 | 627 | 628 |
629 |
630 | ` 631 | }) 632 | export class SharingAReferenceComponent { 633 | state$ = new ReplaySubject(1); 634 | 635 | formGroup$: Observable = this.state$ 636 | .pipe( 637 | startWith({}), 638 | map(input => this.getFormGroupFromConfig(input)) 639 | ); 640 | 641 | @Input() 642 | set formGroupModel(value) { 643 | this.state$.next(value); 644 | } 645 | 646 | @Output() formValueChange = this.formGroup$ 647 | .pipe(switchMap((fg: FormGroup) => fg.valueChanges)); 648 | 649 | constructor(private fb: FormBuilder) { 650 | 651 | } 652 | 653 | getFormGroupFromConfig(modelFromInput) { 654 | const config = Object.entries(modelFromInput) 655 | .reduce((c, [name, initialValue]) => ({...c, [name]: [initialValue]}), {}); 656 | return this.fb.group(config); 657 | } 658 | 659 | } 660 | ``` 661 | _(used RxJS parts: [startWith](https://rxjs.dev/api/operator/startWith))_ 662 | 663 | 664 | If we run the code we will see that the values are not updating in the parent component. 665 | 666 | We faced a problem related to the fact that nearly all observables are cold, which means that every subscriber will get its instance of the producer. 667 | 668 | You might be even more curious now, as our source that produces the formGroup is a `ReplaySubject`. 669 | Which are multi-casting values and sharing one producer with multiple subscribers... 670 | 671 | What we forgot here is that our `formGroup$` observable ends with a `map` operator, 672 | which turns everything after it again into a uni-cast observable. 673 | 674 | So what happened? 675 | We subscribed once in the template over the `async` pipe to render the form. 676 | And another time in the component internals to emit value changes from the form. 677 | 678 | As we now know that the map operator turned everything into a uni-cast observable again, 679 | we realize that we created a new `FormGroup` instance for every subscription. 680 | The subscription in the template, as well as the subscription, happens internally over @Output(). 681 | 682 | This can be solved by adding a multicast operator like `share` or `shareReplay` at the end of `formGroup$`. 683 | 684 | As we also have late subscribers, the `async` pipe in the template, we use `shareReplay` with `bufferSize` 1 serve them the actual formGroup instance. 685 | 686 | ```typescript 687 | formGroup$: Observable = this.state$ 688 | .pipe( 689 | startWith({}), 690 | map(input => this.getFormGroupFromConfig(input)), 691 | shareReplay({refCount: true, bufferSize: 1}) 692 | ); 693 | ``` 694 | `shareReplay` emits **the same value** to subscribers. 695 | 696 | 697 | So the subscription in the template and the subscription in the components internals receive **the same instance** of `FormGroup`. 698 | 699 | Important to notice here is that `shareReplay` is cold but multicast. 700 | This means it only subscribes to the source if at least one subscriber is present. 701 | This does not solve the problem of cold composition but it is fine to share specific work or in this case a reference. 702 | 703 | Later on, in this article, we will remember this problem to provide a way to share work instances. 704 | 705 | ## The Late Subscriber Problem 706 | 707 | In this section, I faced the first time a problem that needed some more thinking. 708 | 709 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/late-subscriber__michael-hladky.png "Late Subscriber") 710 | 711 | Incoming values arrive before the subscription has happened. 712 | 713 | For example state over `@Input()` decorators arrive before the view gets rendered and a used pipe could receive the value. 714 | 715 | 716 | ```typescript 717 | @Component({ 718 | selector: 'app-late-subscriber', 719 | template: ` 720 | {{state$ | async | json}} 721 | ` 722 | }) 723 | export class LateSubscriberComponent { 724 | state$ = new Subject(); 725 | 726 | @Input() 727 | set state(v) { 728 | this.state$.next(v); 729 | } 730 | 731 | } 732 | ``` 733 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-late-subscriber-problem__michael-hladky.png "Late Subscriber - Problem") 734 | 735 | We call this situation a late subscriber problem. In this case, the view is a late subscribe to the values from '@Input()' properties. 736 | There are several situations from our previous explorations that have this problem: 737 | - Input Decorators 738 | - transporting values from `@Input` to `AfterViewInit` hook 739 | - transporting values from `@Input` to the view 740 | - transporting values from `@Input` to the constructor 741 | - Component And Directive Life Cycle Hooks 742 | - transporting `OnChanges` to the view 743 | - getting the state of any life cycle hook later in time (important when hooks are composed) 744 | - Local State 745 | - transporting the current local state to the view 746 | - getting the current local state for other compositions 747 | 748 | 749 | A quick solution here would replay the latest notification to use a ReplaySubject with bufferSize 1. 750 | This would cache the latest emitted value and replay it when the async pipe subscribes. 751 | 752 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/late-subscriber-solution__michael-hladky.png "Late Subscriber - First Solution") 753 | 754 | **Primitive Solution** 755 | ```typescript 756 | @Component({ 757 | selector: 'app-late-subscriber', 758 | template: ` 759 | {{state$ | async | json}} 760 | ` 761 | }) 762 | export class LateSubscriberComponent { 763 | state$ = new ReplaySubject(1); 764 | 765 | @Input() 766 | set state(v) { 767 | this.state$.next(v); 768 | } 769 | 770 | } 771 | ``` 772 | _(used RxJS parts: [ReplaySubject](https://rxjs.dev/api/class/ReplaySubject))_ 773 | 774 | This quick solution has 2 major caveats! 775 | 776 | **First Caveat:** 777 | The downside here is that we can only replay the latest value emitted. 778 | Replaying more values would cause problems for later compositions of this stream, 779 | as a new subscriber would get all past values of the `@Input` Binding. 780 | And that's not what we want. 781 | 782 | More important here is the fact that **we push workload to the consumer**. 783 | We can not assume everybody adopts that. 784 | 785 | If we would make every source replay at least the last value we would have to implement this logic in the following places: 786 | - View Input bindings (multiple times) 787 | - View events (multiple times) 788 | - Other Service Changes (multiple times) 789 | - Component Internal interval (multiple times) 790 | 791 | It would also force the parts to cache values and increase memory. 792 | Furthermore, it would force the third party to implement this too. 793 | 794 | IMHO not scalable. 795 | 796 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-sate-subscriber-replay-caveat-workload__michael-hladky.png "Caveat Workload") 797 | 798 | Another downside is the bundle size of ShareReplay. 799 | But it will be used anyway somewhere in our architecture, so it's a general downside. 800 | 801 | **Second Caveat:** 802 | The second and more tricky caveat is composition is still cold. 803 | we rely on the consumer to initialize state composition. 804 | 805 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-sate-subscriber-replay-caveat-cold-composition__michael-hladky.png "Caveat Cold Composition") 806 | 807 | ### Cold Composition 808 | 809 | Let's quickly clarify hot/cold and uni-case/multi-cast. 810 | 811 | First, let's remember what we learned about uni- and multi-casting in the earlier chapter. 812 | 813 | **Uni-cast** 814 | The producer is unique per subscription. 815 | Any creation operator is uni-cast. (publish operators are not yet refactored to creation operators, but they would be the only exception) 816 | `interval` for example would call `setInterval` for every subscriber separately. 817 | 818 | **Multi-cast** 819 | The producer is shared over all subscriptions. 820 | `Subject` for example emits it's value to multiple subscribers without executing some producer logic again. 821 | 822 | **Cold** 823 | The internal logic of the observable is executed only on subscription. 824 | The consumer controls the moment when internal logic is executed by the `subscribe` is called. 825 | The `interval` creation operator, for example, will only start it's internal tick if we subscribe to it. 826 | Also, nearly every pipe-able operator will execute only if we have an active subscriber. 827 | 828 | An interesting example for a *cold* operator is `share`. 829 | Even if is multi-casts it's notifications to multiple subscribers, 830 | it will not emit any notification until at least one subscriber is present. 831 | 832 | So it's cold at the beginning but multi-cast after the first subscriber. :) 833 | 834 | **Hot** 835 | The internal logic is executed independently from any consumer. 836 | The `Subject` for example can emit values without any present consumer. 837 | 838 | There is also an operator that turn all the above logic into a hot path. 839 | `multicast` and every `publish` operator returns a `ConnectableObservable`. 840 | If we call `connect` on it we connect to the source an start to execute the logic and all the operators in between `publish` and it's source observable. 841 | 842 | --- 843 | 844 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-hot-cold_unicast-multicast.png "Hot vs Cold, Unicast vs Multicast") 845 | 846 | --- 847 | 848 | With this in mind, we can discuss the problem of cold composition in the case of our **local state**. 849 | 850 | As we will have to deal with: 851 | - View Interaction ( button click ) 852 | - Global State Changes ( e.g. HTTP update ) 853 | - Component State Changes ( triggered internal logic ) 854 | 855 | Putting all this logic in the component class is a bad idea. 856 | Not only because of the separations of concerns but also because we would have to implement it over and over again. 857 | 858 | We need to create the logic that deals with the problems around the composition 859 | in a way that it can be reused and is independent! 860 | 861 | So far our sources got subscribed to when the view was ready and we rendered the state. 862 | As the input from the view is a hot producer of values and injected services too we have to decouple 863 | the service that handles component state from other sources. 864 | 865 | **So what is the problem?!!11** 866 | 867 | We have hot sources and we have to compose them. As we already learned in the section _sharing work and instances_ nearly every operator returns a cold source. 868 | No matter if it was hot before or not. 869 | 870 | If we compose state we have to consider that our `scan` operator returns a cold observable. 871 | 872 | So no matter what we do before, after an operation we get a cold observable, and we have to subscribe to it to trigger the composition. 873 | I call this situation a cold composition. 874 | 875 | Some of our sources are cold. This can be solved in tow ways: 876 | - a) Make all sources replay at least their last value (push workload to all relevant sources) 877 | - b) Make the composition hot as early as possible (push workload to the component related part) 878 | 879 | We discuss a) already in the previous section. 880 | This solution pushed workload to all involved parties, and the initiation will still be controlled by the consumer. 881 | 882 | What would be the scenario with b)? 883 | 884 | We could think of the earliest possible moment to make the composition hot. 885 | From the diagram about lifecycle hooks and the different instances in Angular we know that a service, even if locally provided, is instantiated first, before the component. 886 | 887 | If we would put it there we could take over the workload from: 888 | - View Input bindings (multiple times) 889 | - View events (multiple times) 890 | - Component Internal interval (multiple times) 891 | - Locally provided services 892 | - Global services 893 | 894 | We could also get rid of the dependency on their subscription. 895 | 896 | Let's see a simple example where we rely on the consumer to start the composition. 897 | 898 | **Service:** 899 | ```typescript 900 | export class SomeService { 901 | commands$ = new Subject(); 902 | composedState$ = this.commands$ 903 | .pipe( 904 | tap(v => console.log('compute state ', v)), 905 | scan((acc, i) => { 906 | return {sum : acc['sum'] + i['sum']}; 907 | }, {sum: 0}), 908 | // operator here 909 | shareReplay({refCount: true, bufferSize: 1}) 910 | ); 911 | } 912 | ``` 913 | _(used RxJS parts: [scan](https://rxjs.dev/api/operator/scan))_ 914 | 915 | In this service we could try to solve our problem by using: 916 | - share() 917 | - shareReplay({refCount: true, bufferSize: 1}) 918 | - shareReplay({refCount: false, bufferSize: 1}) 919 | 920 | **Component:** 921 | ```typescript 922 | @Component({ 923 | selector: 'cold-composition', 924 | template: ` 925 |

Cold Composition

926 |
927 | 928 |
929 | someService.composedState$: {{someService.composedState$ | async | json}} 930 |
931 | `, 932 | providers: [SomeService] 933 | }) 934 | export class ColdCompositionComponent { 935 | isOpen = false; 936 | 937 | constructor(public someService: SomeService) { 938 | 939 | } 940 | 941 | updateState() { 942 | this.someService.commands$.next({sum: 1}) 943 | } 944 | 945 | } 946 | ``` 947 | 948 | If we run the code and click the button first and then open the result area we see we missed the values emitted before opening the area. 949 | 950 | No matter which of the above ways we try, nothing works. 951 | We always lose values if no subscriber is present. 952 | 953 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-sate-subscriber-replay-cold-composition-problem__michael-hladky.png "Cold Composition - Problem") 954 | 955 | Even if the source is hot (the subject in the service is defined on instantiation) the composition over `scan` made the stream cold again. 956 | This means the composed values can be received only if there is at least 1 subscriber. 957 | In our case, the subscriber was the components `async` pipe in the template. 958 | 959 | Let's see how we can implement the above in a way we could run hot composition: 960 | 961 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-sate-subscriber-replay-cold-composition-solution__michael-hladky.png "Cold Composition - Solution") 962 | 963 | **Hot Composition Service:** 964 | ```typescript 965 | export class SomeService { 966 | subscription = new Subscription(); 967 | 968 | commands$ = new Subject(); 969 | composedState$ = this.commands$ 970 | .pipe( 971 | tap(v => console.log('compute state ', v)), 972 | scan((acc, i) => { 973 | return {sum : acc['sum'] + i['sum']}; 974 | }, {sum: 0}), 975 | // operator here 976 | publishReplay(1) 977 | ); 978 | 979 | constructor() { 980 | // Composition is hot from here on 981 | this.serviceSubscription = this.composedState$.connect(); 982 | } 983 | 984 | } 985 | ``` 986 | _(used RxJS parts: [publishReplay](https://rxjs.dev/api/operator/publishReplay), [Subscription](https://rxjs.dev/api/class/Subscription))_ 987 | 988 | We kept the component untouched and only applied changes to the service. 989 | 990 | We used the `publishReplay` operator to make the 991 | source replay the last emitted value by using `1` as `bufferSize`. 992 | 993 | In the service constructor, we called `connect` to make it hot subscribe to the source. 994 | 995 | ## Subscription-Less Interaction with Component StateManagement 996 | 997 | So far we only had focused on independent peace and didn't pay much attention to their interaction. 998 | Let's analyze the way we interact with components and services so far. 999 | 1000 | Well, known implementations of sate management like `@ngrx/store` in angular, 1001 | which is a global state management library, implemented parts of the consumer-facing API imperatively. 1002 | Also, all implementations of REDUX in react did it like that. 1003 | 1004 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-sate-declarative-interaction-setter__michael-hladky.png "Subscription-Less Component - Problem with setter") 1005 | 1006 | The provided method is `dispatch` which accepts a single value, an action, that gets sent to the store. 1007 | 1008 | Let's look at a simple example: 1009 | 1010 | **Imperative Interaction Service** 1011 | ```typescript 1012 | export class StateService { 1013 | ... 1014 | 1015 | private stateSubject = new Subject<{ [key: string]: any }>(); 1016 | 1017 | // setter like dispatch 1018 | setState(v) { 1019 | this.stateSubject.next(v); 1020 | } 1021 | 1022 | ... 1023 | 1024 | } 1025 | ``` 1026 | 1027 | **Imperative Interaction Component** 1028 | ```typescript 1029 | @Component({ 1030 | selector: 'component', 1031 | template: ` 1032 |

Imperative Interaction

1033 |
{{state$ | async | json}}
1034 | 1037 | `, 1038 | 1039 | providers: [StateService] 1040 | }) 1041 | export class AnyComponent implements OnDestroy { 1042 | state$ = this.stateService.state$; 1043 | 1044 | constructor(private stateService: StateService) { 1045 | 1046 | } 1047 | 1048 | updateState() { 1049 | this.stateService 1050 | .dispatch(({key: value})); 1051 | } 1052 | 1053 | } 1054 | ``` 1055 | 1056 | Why is this imperative? Imperative programming means working with instances and mutating state. 1057 | Whenever you write a `setter` or `getter` your code is imperative, It's not compose-able. 1058 | 1059 | If we now think about the `dispatch` method of `@ngrx/store`, we realize that it is similar to working with `setter`. 1060 | 1061 | While in this example it sits inside another un-compose-able thing, the instance method and therefore is ok. 1062 | Everything else would resolve in more refactoring. 1063 | 1064 | However, we can not use it to work with compose-able sources. 1065 | 1066 | Let's think about connection RouterState or any other source like `ngrx/store` to the local state: 1067 | 1068 | **Imperative Interaction Component** 1069 | ```typescript 1070 | @Component({ 1071 | selector: 'component', 1072 | template: ` 1073 |

Imperative Interaction

1074 |
{{state$ | async | json}}
1075 | `, 1076 | 1077 | providers: [StateService] 1078 | }) 1079 | export class AnyComponent implements OnDestroy { 1080 | subscription = new Subscription(); 1081 | state$ = this.stateService.state$; 1082 | 1083 | constructor(private stateService: StateService, 1084 | private store: Store) { 1085 | 1086 | this.subscription.add( 1087 | this.store.select(getStateSlice) 1088 | .subscribe(value => this.stateService 1089 | .setState({key: value}) 1090 | ) 1091 | ); 1092 | 1093 | } 1094 | 1095 | ngOnDestroy() { 1096 | this.subscription.unsubscribe(); 1097 | } 1098 | 1099 | } 1100 | ``` 1101 | 1102 | As we can see as soon as we deal with something compose-able setters don't work anymore. 1103 | We end up in a very ugly code. We break the reactive flow and we have to take care of subscriptions. 1104 | 1105 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-declarative-interaction-breaking-flow__michael-hladky.png "Subscription-Less Component - Breaking the reactive flow") 1106 | 1107 | But how can we go more declarative or even reactive? 1108 | **By providing something compose-able** :) 1109 | 1110 | Like an observable itself. :) 1111 | 1112 | By adding a single line of code we can go **fully declarative** as well as **fully subscription-less**. 1113 | 1114 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-declarative-interaction-connector__michael-hladky.png "Subscription-Less Component - Connector") 1115 | 1116 | **Declarative Interaction Service** 1117 | ```typescript 1118 | export class StateService implements OnDestroy { 1119 | ... 1120 | private stateSubject = new Subject>(); 1121 | state$ = this.stateSubject 1122 | .pipe( 1123 | // process observables of state changes 1124 | mergeAll(), 1125 | ... 1126 | ); 1127 | 1128 | // "connector" takes observable (compose-able) 1129 | connectState(o) { 1130 | this.stateSubject.next(o); 1131 | } 1132 | ... 1133 | } 1134 | ``` 1135 | _(used RxJS parts: [mergeAll](https://rxjs.dev/api/operator/mergeAll))_ 1136 | 1137 | **Declarative Interaction Component** 1138 | ```typescript 1139 | @Component({ 1140 | selector: 'component', 1141 | template: ` 1142 |

Imperative Interaction

1143 |
{{state$ | async | json}}
1144 | `, 1145 | 1146 | providers: [StateService] 1147 | }) 1148 | export class AnyComponent { 1149 | state$ = this.stateService.state$; 1150 | 1151 | constructor(private stateService: StateService, 1152 | private store: Store) { 1153 | this.stateService.connectState( 1154 | this.store.select(getStateSlice) 1155 | .pipe(map(value => ({key: value}))) 1156 | ); 1157 | 1158 | } 1159 | } 1160 | ``` 1161 | 1162 | Let's take a detailed look at the introduced changes: 1163 | 1164 | 1. In `StateService` we changed 1165 | `stateSubject = new Subject<{ [key: string]: any }>();` 1166 | to 1167 | `stateSubject = new Subject>();` 1168 | It now accepts Observables instead of state objects. 1169 | 2. In `StateService` we added the `mergeAll()` operator to our state computation logic. 1170 | 3. In `StateService` we replaced the `setState` method 1171 | ``` 1172 | setState(v: { [key: string]: any }) { 1173 | this.stateSubject.next(v); 1174 | } 1175 | ``` 1176 | that took a single value to `connectState` 1177 | ``` 1178 | connectState(o: Observable<{ [key: string]: any }>) { 1179 | this.stateSubject.next(o); 1180 | } 1181 | ``` 1182 | 1183 | By providing the whole observable we can handle all related mechanisms 1184 | of subscription handling, as well as value processing and emission in the service itself and hide all this away from others. 1185 | 1186 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-declarative-interaction-connector-code__michael-hladky.png "Subscription-Less Component - Connect method") 1187 | 1188 | We now have not only way less and maintainable code but also a "subscription-less component". 1189 | 1190 | This simple change will enable us to do create way more than just subscription-Less components. 1191 | But this document is dedicated to the very fundamentals. 1192 | 1193 | As a last additional benefit in this section we can talk a little bit about side-effects: 1194 | 1195 | ### Subscription-Less Handling of Side-Effects 1196 | 1197 | If we recap the above snippet we can see that we not only introduced a subscription-less way for state management interaction, 1198 | but also a very elegant way to handle side-effects over our service. 1199 | 1200 | Let's implement some more lines of code: 1201 | 1202 | **Service** 1203 | ```typescript 1204 | export class DeclarativeSideEffectsGoodService implements OnDestroy { 1205 | private effectSubscription = new Subscription(); 1206 | private effectSubject = new Subject>(); 1207 | 1208 | constructor() { 1209 | this.effectSubscription = (this.effectSubject 1210 | .pipe(mergeAll(), publish()) as ConnectableObservable).connect(); 1211 | } 1212 | 1213 | ngOnDestroy(): void { 1214 | this.effectSubscription.unsubscribe(); 1215 | } 1216 | 1217 | connectEffect(o: Observable) { 1218 | this.effectSubject.next(o); 1219 | } 1220 | 1221 | } 1222 | ``` 1223 | **Declarative Interaction Component** 1224 | ```typescript 1225 | @Component({ 1226 | selector: 'declarative-side-effects', 1227 | template: ` 1228 |

Declarative SideEffects

1229 | `, 1230 | providers: [StateAndEffectService] 1231 | }) 1232 | export class AnyComponent { 1233 | constructor(private stateService: StateAndEffectService) { 1234 | this.stateService.connectEffect(interval(1000) 1235 | .pipe(tap(_ => ({key: value})))); 1236 | } 1237 | 1238 | } 1239 | ``` 1240 | _(used RxJS parts: [publish](https://rxjs.dev/api/operator/publish) ))_ 1241 | 1242 | Note that the side-effect is now placed in a `tap` operator and the whole observable is handed over. 1243 | 1244 | ## Recap Problems 1245 | 1246 | So far we encountered the following problems: 1247 | - sharing work and references 1248 | - subscription handling 1249 | - late subscriber 1250 | - cold composition 1251 | - moving primitive tasks as subscription handling and state composition into another layer 1252 | - Subscription-less components and declarative interaction 1253 | 1254 | If you may already realize all the above problems naturally collapse into a single piece of code. 1255 | :) 1256 | 1257 | Also if you remember from the beginning this is what "the gang of four" quote says about Object-Oriented Design Patterns. 1258 | :) 1259 | 1260 | We can be happy as we did a great job so far. 1261 | We focused on understanding the problems, we used the language specific possibilities the right way, 1262 | and naturally, we ended up with a solution that is compact, robust and solves all related problems in an elegant way. 1263 | 1264 | Let's see how the local state service looks like. 1265 | ![](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/raw/master/images/reactive-local-state-first-draft__michael-hladky.png "Reactive Ephemeral State - First Draft") 1266 | 1267 | 1268 | # Basic Usage 1269 | 1270 | ## Service Design 1271 | 1272 | **State Logic** 1273 | ```typescript 1274 | import {ConnectableObservable, merge, noop, Observable, OperatorFunction, Subject, Subscription, UnaryFunction} from 'rxjs'; 1275 | import {map, mergeAll, pluck, publishReplay, scan, tap} from 'rxjs/operators'; 1276 | 1277 | export function stateful() { 1278 | return (o: Observable): Observable => { 1279 | return o.pipe( 1280 | filter(v => v !== undefined), 1281 | distinctUntilChanged(), 1282 | shareReplay(1) 1283 | ); 1284 | }; 1285 | } 1286 | 1287 | function pipeFromArray(fns: Array>): UnaryFunction { 1288 | if (!fns) { 1289 | return noop as UnaryFunction; 1290 | } 1291 | 1292 | if (fns.length === 1) { 1293 | return fns[0]; 1294 | } 1295 | 1296 | return function piped(input: T): R { 1297 | return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any); 1298 | }; 1299 | } 1300 | 1301 | export class State { 1302 | private subscription = new Subscription(); 1303 | private stateObservables = new Subject>>(); 1304 | private effectSubject = new Subject(); 1305 | private stateSlices = new Subject>(); 1306 | 1307 | private state$ = merge( 1308 | this.stateObservables.pipe(mergeAll()), 1309 | this.stateSlices 1310 | ).pipe( 1311 | scan(this.stateAccumulator, {} as T), 1312 | publishReplay(1) 1313 | ); 1314 | 1315 | constructor() { 1316 | this.init(); 1317 | } 1318 | 1319 | private stateAccumulator(acc: T, command: Partial): T { 1320 | const a = (acc as any) as object; 1321 | const c = (command as any) as object; 1322 | return ({...a, ...c} as T); 1323 | } 1324 | 1325 | init() { 1326 | this.subscription.add((this.state$ as ConnectableObservable).connect()); 1327 | this.subscription.add( 1328 | this.effectSubject.pipe(mergeAll()) 1329 | .subscribe() 1330 | ); 1331 | } 1332 | 1333 | 1334 | setState(s: Partial): void { 1335 | this.stateSlices.next(s); 1336 | } 1337 | 1338 | connectState(str: A, obs: Observable): void; 1339 | connectState(obs: Observable>): void; 1340 | connectState(strOrObs: any, obs?: any): void { 1341 | if (typeof strOrObs === 'string') { 1342 | this.stateObservables.next(obs.pipe(map(s => ({[strOrObs as A]: s}))) as Observable); 1343 | } else { 1344 | this.stateObservables.next(strOrObs); 1345 | } 1346 | } 1347 | 1348 | select(): Observable; 1349 | // ======================== 1350 | select( 1351 | op: OperatorFunction 1352 | ): Observable; 1353 | select( 1354 | op1: OperatorFunction, 1355 | op2: OperatorFunction 1356 | ): Observable; 1357 | select( 1358 | op1: OperatorFunction, 1359 | op2: OperatorFunction, 1360 | op3: OperatorFunction 1361 | ): Observable; 1362 | select( 1363 | op1: OperatorFunction, 1364 | op2: OperatorFunction, 1365 | op3: OperatorFunction, 1366 | op4: OperatorFunction, 1367 | ): Observable; 1368 | select( 1369 | op1: OperatorFunction, 1370 | op2: OperatorFunction, 1371 | op3: OperatorFunction, 1372 | op4: OperatorFunction, 1373 | op5: OperatorFunction, 1374 | ): Observable; 1375 | // ================================ 1376 | select(k1: K1): Observable; 1377 | select(k1: K1, k2: K2): Observable; 1379 | select(k1: K1, k2: K2, k3: K3): Observable; 1382 | select(k1: K1, k2: K2, k3: K3, k4: K4): Observable; 1386 | select(k1: K1, k2: K2, k3: K3, k4: K4, k5: K5): Observable; 1391 | select(k1: K1, k2: K2, k3: K3, k4: K4, k5: K5, k6: K6): Observable; 1397 | // =========================== 1398 | select(...opOrMapFn: any[]): Observable { 1399 | if (!opOrMapFn || opOrMapFn.length === 0) { 1400 | return this.state$ 1401 | .pipe( 1402 | stateful() 1403 | ); 1404 | } else if (!this.isStringArray(opOrMapFn)) { 1405 | const path = (opOrMapFn as any) as string[]; 1406 | return this.state$.pipe( 1407 | pluck(...path), 1408 | stateful() 1409 | ); 1410 | } else if (this.isOperateFnArray(opOrMapFn)) { 1411 | const oprs = opOrMapFn as OperatorFunction[]; 1412 | return this.state$.pipe( 1413 | pipeFromArray(oprs), 1414 | stateful() 1415 | ); 1416 | } 1417 | 1418 | throw new Error('Wrong params passed' + JSON.stringify(opOrMapFn)); 1419 | } 1420 | 1421 | holdEffect(observableWithSideEffect: Observable): void; 1422 | holdEffect(obsOrObsWithSideEffect: Observable, sideEffectFn?: (arg: S) => void): void { 1423 | if (sideEffectFn) { 1424 | this.effectSubject.next(obsOrObsWithSideEffect.pipe(tap(sideEffectFn))); 1425 | } 1426 | this.effectSubject.next(obsOrObsWithSideEffect); 1427 | } 1428 | 1429 | teardown(): void { 1430 | this.subscription.unsubscribe(); 1431 | } 1432 | 1433 | private isOperateFnArray(op: any[]): op is OperatorFunction[] { 1434 | return op.every((i: any) => typeof i !== 'string'); 1435 | } 1436 | 1437 | private isStringArray(op: any[]): op is string[] { 1438 | return op.every((i: any) => typeof i !== 'string'); 1439 | } 1440 | 1441 | } 1442 | ``` 1443 | 1444 | ## Service Implementation 1445 | 1446 | Now let us see some minimal examples on how to use the service: 1447 | 1448 | **Extending the service** 1449 | ```typescript 1450 | @Component({ 1451 | selector: 'component', 1452 | template: ` 1453 |

Component

1454 | ... 1455 | ` 1456 | }) 1457 | export class AnyComponent extends LocalState { 1458 | 1459 | constructor() { 1460 | this.super(); 1461 | } 1462 | 1463 | } 1464 | ``` 1465 | 1466 | **Injecting the service** 1467 | ```typescript 1468 | @Component({ 1469 | selector: 'component', 1470 | template: ` 1471 |

Component

1472 | ... 1473 | `, 1474 | providers: [LocalState] 1475 | }) 1476 | export class AnyComponent { 1477 | 1478 | constructor(private stateService: LocalState) { 1479 | } 1480 | 1481 | } 1482 | ``` 1483 | 1484 | ## Service Usage 1485 | 1486 | Now let's see some basic usage: 1487 | 1488 | **Connecting Input-Bindings** 1489 | ```typescript 1490 | @Component({ 1491 | selector: 'component', 1492 | template: ` 1493 |

Component

1494 | ... 1495 | ` 1496 | }) 1497 | export class AnyComponent extends LocalState { 1498 | 1499 | @Input() 1500 | set value(value) { 1501 | this.setState({slice: value}) 1502 | } 1503 | 1504 | constructor() { 1505 | this.super(); 1506 | } 1507 | 1508 | } 1509 | ``` 1510 | 1511 | **Connecting GlobalState** 1512 | ```typescript 1513 | @Component({ 1514 | selector: 'component', 1515 | template: ` 1516 |

Component

1517 | ... 1518 | ` 1519 | }) 1520 | export class AnyComponent extends LocalState { 1521 | 1522 | @Input() 1523 | set value(value) { 1524 | this.setState({slice: value}) 1525 | } 1526 | 1527 | constructor(private store: Store) { 1528 | this.connectState( 1529 | this.store.select(getStateSlice) 1530 | .pipe(transformation) 1531 | ); 1532 | } 1533 | 1534 | } 1535 | ``` 1536 | 1537 | **Selecting LocalState** 1538 | ```typescript 1539 | @Component({ 1540 | selector: 'component', 1541 | template: ` 1542 |

Component

1543 | 1544 | {{state$ | async}} 1545 | ` 1546 | }) 1547 | export class AnyComponent extends LocalState { 1548 | input$ = new Subject(); 1549 | 1550 | state$ = this.select( 1551 | withLatestFrom(input$), 1552 | map(([state, _]) => state.slice) 1553 | ); 1554 | 1555 | } 1556 | ``` 1557 | _(used RxJS parts: [withLatestFrom](https://rxjs.dev/api/operator/withLatestFrom))_ 1558 | 1559 | **Handling LocalSideEffects** 1560 | 1561 | ```typescript 1562 | @Component({ 1563 | selector: 'component', 1564 | template: ` 1565 |

Component

1566 | ... 1567 | ` 1568 | }) 1569 | export class AnyComponent extends LocalState { 1570 | 1571 | constructor(private store: Store) { 1572 | this.connectEffect(interval(10000) 1573 | .pipe(tap(this.store.dispatch(loadDataAction))) 1574 | ); 1575 | } 1576 | 1577 | } 1578 | ``` 1579 | 1580 | This example shows a material design list that is collapsable. 1581 | It refreshed data every n seconds of if we click the button. 1582 | Also, it displays the fetched items. 1583 | 1584 | 1585 | *Basic Example - Stateful Component**: 1586 | ```typescript 1587 | @Component({ 1588 | selector: 'basic-list', 1589 | template: ` 1590 | 1594 | 1595 | 1596 | User Name 1597 | 1598 | 1599 | {{m.list.length}} Repositories Updated every: {{m.refreshInterval}} 1600 | ms 1601 | {{m.list.length}} 1602 | 1603 | 1604 | 1605 | 1609 | 1610 |
1611 | 1612 | 1613 | {{item.name}} 1614 | 1615 | 1616 |
1617 | 1618 | 1619 | No list given! 1620 | 1621 | 1622 |
1623 | `, 1624 | changeDetection: ChangeDetectionStrategy.OnPush 1625 | }) 1626 | export class DemoBasicsComponent3 extends LocalState { 1627 | initComponentState = { 1628 | refreshInterval: 10000, 1629 | listExpanded: false, 1630 | list: [] 1631 | }; 1632 | refreshClicks = new Subject(); 1633 | listExpandedChanges = new Subject(); 1634 | 1635 | model$ = this.select(); 1636 | 1637 | @Input() 1638 | set refreshInterval(refreshInterval: number) { 1639 | this.setState({refreshInterval}); 1640 | } 1641 | 1642 | refreshListSideEffect$ = merge( 1643 | this.refreshClicks, 1644 | this.select( 1645 | map(s => s.refreshInterval), 1646 | tap(console.log), 1647 | switchMap(ms => timer(0, ms)) 1648 | ) 1649 | ) 1650 | .pipe( 1651 | tap(_ => this.store.dispatch(fetchRepositoryList({}))) 1652 | ); 1653 | 1654 | constructor(private store: Store) { 1655 | super(); 1656 | this.setState(this.initComponentState); 1657 | this.connectState(this.listExpandedChanges 1658 | .pipe(map(b => ({listExpanded: b})))); 1659 | this.holdEffect(this.refreshListSideEffect$); 1660 | this.connectState('list', 1661 | this.store.select(selectRepositoryList).pipe(map(this.parseListItems)) 1662 | ); 1663 | } 1664 | 1665 | parseListItems(l: RepositoryListItem[]): DemoBasicsItem[] { 1666 | return l.map(({id, name}) => ({id, name})) 1667 | } 1668 | 1669 | } 1670 | ``` 1671 | 1672 | This shows some fundamental interaction for template global state and ephemeral state. 1673 | The next snippet shows how you would implement architecture patterns based on that service. In this case I picked a simple implementation of the MVVM design pattern. 1674 | 1675 | *Basic Example - Design Pattern MVVM**: 1676 | 1677 | ```typescript 1678 | export interface DemoBasicsBaseModel { 1679 | refreshInterval: number; 1680 | list: DemoBasicsItem[]; 1681 | listExpanded: boolean; 1682 | } 1683 | 1684 | export interface DemoBasicsView { 1685 | refreshClicks: Subject; 1686 | listExpandedChanges: Subject 1687 | baseModel$: Observable; 1688 | } 1689 | 1690 | @Injectable() 1691 | export class DemoBasicsViewModelService extends LocalState implements DemoBasicsView { 1692 | initState: DemoBasicsBaseModel = { 1693 | refreshInterval: 1000, 1694 | listExpanded: true, 1695 | list: [] 1696 | } 1697 | 1698 | baseModel$ = this.select(); 1699 | 1700 | refreshClicks = new Subject(); 1701 | listExpandedChanges = new Subject(); 1702 | 1703 | refreshListSideEffect$ = merge( 1704 | this.refreshClicks, 1705 | this.select(map(s => s.refreshInterval)) 1706 | .pipe(switchMap(ms => timer(ms))) 1707 | ); 1708 | 1709 | constructor() { 1710 | super(); 1711 | this.setState(this.initState); 1712 | 1713 | this.connectState(this.listExpandedChanges 1714 | .pipe(map(b => ({listExpanded: b}))) 1715 | ); 1716 | } 1717 | 1718 | } 1719 | 1720 | 1721 | 1722 | @Component({ 1723 | selector: 'basic-list', 1724 | template: ` 1725 | 1729 | 1730 | 1731 | User Name 1732 | 1733 | 1734 | {{bm.list.length}} 1735 | Repositories Updated every: {{bm.refreshInterval}} 1736 | ms 1737 | {{bm.list.length}} 1738 | 1739 | 1740 | 1741 | 1745 | 1746 |
1747 | 1748 | 1749 | {{item.name}} 1750 | 1751 | 1752 |
1753 | 1754 | 1755 | No list given! 1756 | 1757 | 1758 |
1759 | `, 1760 | changeDetection: ChangeDetectionStrategy.OnPush, 1761 | providers: [DemoBasicsViewModelService] 1762 | }) 1763 | export class DemoBasicsComponent4 { 1764 | 1765 | @Input() 1766 | set refreshInterval(refreshInterval: number) { 1767 | this.vm.setState({refreshInterval}); 1768 | } 1769 | 1770 | constructor(public vm: DemoBasicsViewModelService, 1771 | private store: Store) { 1772 | this.vm.connectState('list', 1773 | this.store.select(selectRepositoryList).pipe(map(this.parseListItems)) 1774 | ); 1775 | this.vm.holdEffect(this.vm.refreshListSideEffect$ 1776 | .pipe(tap(_ => this.store.dispatch(fetchRepositoryList()))) 1777 | ); 1778 | } 1779 | 1780 | parseListItems(l: RepositoryListItem[]): DemoBasicsItem[] { 1781 | return l.map(({id, name}) => ({id, name})) 1782 | } 1783 | 1784 | } 1785 | 1786 | ``` 1787 | 1788 | The lase example showed how MVVM in implemented based on the reactive state class. What is interesting here is that the template only accesses the ViewModel, nothing else. 1789 | 1790 | But this is part of another document I would suggest. ;p 1791 | 1792 | # Summary 1793 | 1794 | **How to differ global from the ephemeral state:** 1795 | - No horizontal sharing of state 1796 | - The lifetime of the state is dynamic, bound to e.g. a component 1797 | - It processes local relevant events 1798 | 1799 | 1800 | If we take a look at our operator reference list at the end of this document we can see it was a lot about: 1801 | - unicast vs. multicast 1802 | - hot vs. cold 1803 | 1804 | **The main outcome here was we should ensure that the moment of computation of states is not controlled by the subscriber. 1805 | It should be hot.** 1806 | 1807 | We learned how to can have a fully reactive flow with 1808 | - **higher-order operators** like [mergeAll](https://rxjs.dev/api/operators/mergeAll) 1809 | The combination with our logic bound to a certain life-time enabled us to create 1810 | - **subscription-less components** 1811 | 1812 | An example implementation of our learning can be found in the resources. 1813 | 1814 | Based on that we used in a minimal example and also made the first test with some design patterns like MVVM. 1815 | 1816 | --- 1817 | 1818 | **Resources** 1819 | - **Recording** ([🎥 Live Demo at 24:47](https://www.youtube.com/watch?v=I8uaHMs8rw0&t=24m47s)): 1820 | 1821 | [🎥 Angular Vienna, Angular, and RxJS - Tackling Ephemeral State Reactively](https://www.youtube.com/watch?v=I8uaHMs8rw0) 1822 | - **Slides**: 1823 | 1824 | [🖼️ Tackling Component State Reactively](https://docs.google.com/presentation/d/1MGzffMw9qaP1-lYyzDJ0LfC8zKJWxCktK9MN8xauU0Q/edit?usp=sharing) 1825 | - **Repository For Examples** ([💾 Final Example](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/tree/master/src/app/examples/demo-basics)): 1826 | 1827 | [💾 research-on-reactive-ephemeral-state-in-component-oriented-frontend-frameworks](https://github.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/) 1828 | 1829 | - **Sourcecode**: [📦 ngx-rx/rxjs-state](https://github.com/BioPhoton/ngx-rx/tree/master/libs/rxjs-state) 1830 | - **NPM Package**: [📦 ngx-rx-state](https://github.com/BioPhoton/ngx-rx/tree/master/libs/ngx-rx-state) 1831 | 1832 | Used RxJS parts: 1833 | - [interval](https://rxjs.dev/api/function/interval) 1834 | - [timer](https://rxjs.dev/api/function/timer) 1835 | - [tap](https://rxjs.dev/api/operator/tap) 1836 | - [map](https://rxjs.dev/api/operator/map) 1837 | - [mergeAll](https://rxjs.dev/api/operator/mergeAll) 1838 | - [share](https://rxjs.dev/api/operator/share) 1839 | - [shareReplay](https://rxjs.dev/api/operator/shareReplay) 1840 | - [publish](https://rxjs.dev/api/operator/publish) 1841 | - [publishReplay](https://rxjs.dev/api/operator/publishReplay) 1842 | - [takeUntil](https://rxjs.dev/api/operator/takeUntil) 1843 | - [withLatestFrom](https://rxjs.dev/api/operator/withLatestFrom) 1844 | - [Subscription](https://rxjs.dev/api/class/Subscription) 1845 | - [Observable](https://rxjs.dev/api/class/Observable) 1846 | - [Subject](https://rxjs.dev/api/class/Subject) 1847 | - [ReplaySubject](https://rxjs.dev/api/class/ReplaySubject) 1848 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "blog-component-state": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss", 11 | "skipTests": true 12 | }, 13 | "@schematics/angular:class": { 14 | "skipTests": true 15 | }, 16 | "@schematics/angular:directive": { 17 | "skipTests": true 18 | }, 19 | "@schematics/angular:guard": { 20 | "skipTests": true 21 | }, 22 | "@schematics/angular:module": { 23 | "skipTests": true 24 | }, 25 | "@schematics/angular:pipe": { 26 | "skipTests": true 27 | }, 28 | "@schematics/angular:service": { 29 | "skipTests": true 30 | } 31 | }, 32 | "root": "", 33 | "sourceRoot": "src", 34 | "prefix": "app", 35 | "architect": { 36 | "build": { 37 | "builder": "@angular-devkit/build-angular:browser", 38 | "options": { 39 | "outputPath": "dist/blog-component-state", 40 | "index": "src/index.html", 41 | "main": "src/main.ts", 42 | "polyfills": "src/polyfills.ts", 43 | "tsConfig": "tsconfig.app.json", 44 | "aot": true, 45 | "assets": [ 46 | "src/favicon.ico", 47 | "src/assets" 48 | ], 49 | "styles": [ 50 | "src/styles.scss" 51 | ], 52 | "scripts": [] 53 | }, 54 | "configurations": { 55 | "production": { 56 | "fileReplacements": [ 57 | { 58 | "replace": "src/environments/environment.ts", 59 | "with": "src/environments/environment.prod.ts" 60 | } 61 | ], 62 | "optimization": true, 63 | "outputHashing": "all", 64 | "sourceMap": false, 65 | "extractCss": true, 66 | "namedChunks": false, 67 | "extractLicenses": true, 68 | "vendorChunk": false, 69 | "buildOptimizer": true, 70 | "budgets": [ 71 | { 72 | "type": "initial", 73 | "maximumWarning": "2mb", 74 | "maximumError": "5mb" 75 | }, 76 | { 77 | "type": "anyComponentStyle", 78 | "maximumWarning": "6kb", 79 | "maximumError": "10kb" 80 | } 81 | ] 82 | } 83 | } 84 | }, 85 | "serve": { 86 | "builder": "@angular-devkit/build-angular:dev-server", 87 | "options": { 88 | "browserTarget": "blog-component-state:build" 89 | }, 90 | "configurations": { 91 | "production": { 92 | "browserTarget": "blog-component-state:build:production" 93 | } 94 | } 95 | }, 96 | "extract-i18n": { 97 | "builder": "@angular-devkit/build-angular:extract-i18n", 98 | "options": { 99 | "browserTarget": "blog-component-state:build" 100 | } 101 | } 102 | } 103 | } 104 | }, 105 | "defaultProject": "blog-component-state", 106 | "cli": { 107 | "defaultCollection": "@ngrx/schematics" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /images/angular-timeline__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/angular-timeline__michael-hladky.png -------------------------------------------------------------------------------- /images/cover-reactive-local-state__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/cover-reactive-local-state__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-changes_processing-global-sources__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-changes_processing-global-sources__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-changes_processing-local-sources__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-changes_processing-local-sources__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-quote-gang-of-four2__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-quote-gang-of-four2__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-quote-gang-of-four__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-quote-gang-of-four__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-declarative-interaction-breaking-flow__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-declarative-interaction-breaking-flow__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-declarative-interaction-connector-and-state__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-declarative-interaction-connector-and-state__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-declarative-interaction-connector-code__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-declarative-interaction-connector-code__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-declarative-interaction-connector__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-declarative-interaction-connector__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-declarative-interaction-setter__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-declarative-interaction-setter__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-first-draft__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-first-draft__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-hot-cold_unicast-multicast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-hot-cold_unicast-multicast.png -------------------------------------------------------------------------------- /images/reactive-local-state-intro__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-intro__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-late-subscriber__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-late-subscriber__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-subscriber-problem__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-subscriber-problem__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-subscriber-replay-caveat-cold-composition__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-subscriber-replay-caveat-cold-composition__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-subscriber-replay-caveat-workload__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-subscriber-replay-caveat-workload__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-subscriber-replay-cold-composition-problem__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-subscriber-replay-cold-composition-problem__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-subscriber-replay-cold-composition-solution__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-subscriber-replay-cold-composition-solution__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state-sate-subscriber-solution__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state-sate-subscriber-solution__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_ephemeral-state__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_ephemeral-state__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_global-accessible__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_global-accessible__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_layers-of-state__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_layers-of-state__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_lifetime-angular-building-blocks__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_lifetime-angular-building-blocks__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_lifetime-async-pipe__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_lifetime-async-pipe__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_lifetime-global-singleton-service__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_lifetime-global-singleton-service__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_local-accessible__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_local-accessible__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_subscription-handling__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_subscription-handling__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_timing-component-lifecycle__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_timing-component-lifecycle__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_timing-lifecycl-hooks-and-subscriptions-hello-world__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_timing-lifecycl-hooks-and-subscriptions-hello-world__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_uni-case-vs-multi-cast-instance__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_uni-case-vs-multi-cast-instance__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_uni-case-vs-multi-cast-observables__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_uni-case-vs-multi-cast-observables__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_uni-case-vs-multi-cast-operators__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_uni-case-vs-multi-cast-operators__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_uni-case-vs-multi-cast-work__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_uni-case-vs-multi-cast-work__michael-hladky.png -------------------------------------------------------------------------------- /images/reactive-local-state_uni-case-vs-multi-cast__michael-hladky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/reactive-local-state_uni-case-vs-multi-cast__michael-hladky.png -------------------------------------------------------------------------------- /images/researc-ephemeral-state-dramatic-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/images/researc-ephemeral-state-dramatic-title.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-component-state", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e", 11 | "readme:gen-toc": "markdown-toc ./README.md -i" 12 | }, 13 | "private": true, 14 | "dependencies": { 15 | "@angular/animations": "~9.0.0-next.5", 16 | "@angular/cdk": "~8.2.3", 17 | "@angular/common": "~9.0.0-next.5", 18 | "@angular/compiler": "~9.0.0-next.5", 19 | "@angular/core": "~9.0.0-next.5", 20 | "@angular/forms": "~9.0.0-next.5", 21 | "@angular/material": "^8.2.3", 22 | "@angular/platform-browser": "~9.0.0-next.5", 23 | "@angular/platform-browser-dynamic": "~9.0.0-next.5", 24 | "@angular/router": "~9.0.0-next.5", 25 | "@ngrx/effects": "^8.4.0", 26 | "@ngrx/entity": "^8.5.0", 27 | "@ngrx/schematics": "^8.5.0", 28 | "@ngrx/store": "^8.4.0", 29 | "@ngrx/store-devtools": "^8.4.0", 30 | "material": "^0.4.1", 31 | "rxjs": "~6.4.0", 32 | "tslib": "^1.10.0", 33 | "typy": "^3.3.0", 34 | "zone.js": "~0.10.1" 35 | }, 36 | "devDependencies": { 37 | "@angular-devkit/build-angular": "~0.900.0-next.4", 38 | "@angular/cli": "~9.0.0-next.4", 39 | "@angular/compiler-cli": "~9.0.0-next.5", 40 | "@angular/language-service": "~9.0.0-next.5", 41 | "@types/node": "~8.9.4", 42 | "markdown-toc": "^1.2.0", 43 | "object-path": "^0.11.4", 44 | "ts-node": "~8.3.0", 45 | "tslint": "~5.18.0", 46 | "typescript": "~3.5.3", 47 | "webpack-dashboard": "^3.2.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/app-component/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | Menu 7 | 8 |

Problems

9 |
Subscription Handling 10 | Sharing State and State Derivations 11 | Late Subscriber 12 | Cold Composition 13 | Declarative Interaction 14 |

Demo Examples

15 | Collapse-List 16 | 17 | 18 | 19 | 20 | 28 | Tackle Ephemeral-State Reactive 29 | 30 |
31 | 32 |
33 |
34 | 35 | -------------------------------------------------------------------------------- /src/app/app-component/app.component.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 15px; 3 | } 4 | .sidenav-container { 5 | height: 100%; 6 | } 7 | 8 | .sidenav { 9 | width: 250px; 10 | } 11 | 12 | .sidenav .mat-toolbar { 13 | background: inherit; 14 | } 15 | 16 | .mat-toolbar.mat-primary { 17 | position: sticky; 18 | top: 0; 19 | z-index: 1; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/app-component/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {AppViewModel} from "./app.view.model"; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.scss'], 8 | providers: [AppViewModel] 9 | }) 10 | export class AppComponent { 11 | constructor(public vm: AppViewModel) { 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/app-component/app.view.model.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from "@angular/core"; 2 | import {Observable} from "rxjs"; 3 | import {BreakpointObserver, Breakpoints} from "@angular/cdk/layout"; 4 | import {map, shareReplay} from "rxjs/operators"; 5 | 6 | @Injectable() 7 | export class AppViewModel { 8 | isHandset$: Observable = this.breakpointObserver.observe(Breakpoints.Handset) 9 | .pipe( 10 | map(result => result.matches), 11 | shareReplay() 12 | ); 13 | 14 | constructor(private breakpointObserver: BreakpointObserver){} 15 | } 16 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {BrowserModule} from '@angular/platform-browser'; 3 | import {HttpClientModule} from '@angular/common/http'; 4 | import {RouterModule} from '@angular/router'; 5 | import {ReactiveFormsModule} from '@angular/forms'; 6 | import {StoreModule} from '@ngrx/store'; 7 | import {EffectsModule} from '@ngrx/effects'; 8 | import {ROUTES} from "./app.routes"; 9 | import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 10 | import {LayoutModule} from '@angular/cdk/layout'; 11 | import {MatToolbarModule} from '@angular/material/toolbar'; 12 | import {MatButtonModule} from '@angular/material/button'; 13 | import {MatSidenavModule} from '@angular/material/sidenav'; 14 | import {MatIconModule} from '@angular/material/icon'; 15 | import {MatListModule} from '@angular/material/list'; 16 | import {AppComponent} from "./app-component/app.component"; 17 | import {MatCardModule} from "@angular/material"; 18 | import {StoreDevtoolsModule} from '@ngrx/store-devtools'; 19 | import {environment} from '../environments/environment'; 20 | import {GithubModule} from "@data-access/github"; 21 | import {ExampleContainerComponent} from "./examples/examples.container.component"; 22 | 23 | export const materialModules = [ 24 | BrowserAnimationsModule, 25 | LayoutModule, 26 | MatToolbarModule, 27 | MatButtonModule, 28 | MatSidenavModule, 29 | MatIconModule, 30 | MatListModule, 31 | MatCardModule 32 | ]; 33 | 34 | @NgModule({ 35 | imports: [ 36 | BrowserModule, HttpClientModule, ReactiveFormsModule, 37 | StoreModule.forRoot({}), 38 | EffectsModule.forRoot([]), 39 | StoreDevtoolsModule.instrument({maxAge: 25, logOnly: environment.production}), 40 | 41 | RouterModule.forRoot(ROUTES), 42 | materialModules, 43 | GithubModule 44 | ], 45 | declarations: [AppComponent, ExampleContainerComponent], 46 | bootstrap: [AppComponent] 47 | }) 48 | export class AppModule { 49 | 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import {ROUTES as COLD_COMPOSITION_ROUTES} from "./examples/problems/cold-composition/cold-composition.module"; 2 | import {ROUTES as LATE_SUBSCRIBER_ROUTES} from "./examples/problems/late-subscriber/late-subscriber.module"; 3 | import {ROUTES as SUBSCRIPTION_HANDLING_ROUTES} from "./examples/problems/subscription-handling/subscription-handling.module"; 4 | import {ROUTES as SHARING_A_REFERENCE_ROUTES} from "./examples/problems/sharing-a-reference/sharing-a-reference.module"; 5 | import {ROUTES as DECLARATIVE_INTERACTION_ROUTES} from "./examples/problems/declarative-interaction/declarative-interaction.module"; 6 | import {ROUTES as DEMO_BASICS_ROUTES} from "./examples/demo-basics/demo-basics.module"; 7 | import {ExampleContainerComponent} from "./examples/examples.container.component"; 8 | 9 | export const ROUTES = [ 10 | { 11 | path: '', 12 | component: ExampleContainerComponent 13 | }, 14 | {path: 'subscription-handling', children: SUBSCRIPTION_HANDLING_ROUTES}, 15 | {path: 'late-subscriber', children: LATE_SUBSCRIBER_ROUTES}, 16 | {path: 'sharing-a-reference', children: SHARING_A_REFERENCE_ROUTES}, 17 | {path: 'cold-composition', children: COLD_COMPOSITION_ROUTES}, 18 | {path: 'declarative-interaction', children: DECLARATIVE_INTERACTION_ROUTES}, 19 | {path: 'demo-basics', children: DEMO_BASICS_ROUTES} 20 | ]; 21 | -------------------------------------------------------------------------------- /src/app/common/component-state.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, OnDestroy} from '@angular/core'; 2 | import {animationFrameScheduler, ConnectableObservable, Observable, Subject} from 'rxjs'; 3 | import {map, mergeAll, observeOn, publishReplay, scan, takeUntil} from 'rxjs/operators'; 4 | 5 | export interface SliceConfig { 6 | starWith?: any, 7 | endWith?: any, 8 | } 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class ComponentStateService implements OnDestroy { 14 | 15 | onDestroy$ = new Subject(); 16 | 17 | private effectObservable$$ = new Subject(); 18 | private effect$ = this.effectObservable$$ 19 | .pipe( 20 | mergeAll(), 21 | takeUntil(this.onDestroy$), 22 | // @NOTE when scheduled over animation frame the process stops when the user leaving the tab 23 | observeOn(animationFrameScheduler), 24 | publishReplay(1) 25 | ); 26 | 27 | private commandObservable$$ = new Subject(); 28 | private state$: Observable = 29 | this.commandObservable$$ 30 | .pipe( 31 | mergeAll(), 32 | scan((s, c) => { 33 | const [keyToDelete, value]: [string, any] = Object.entries(c)[0]; 34 | const isKeyToDeletePresent = keyToDelete in s; 35 | // The key you want to delete is not stored :) 36 | if (!isKeyToDeletePresent && value === undefined) { 37 | return s; 38 | } 39 | // Delete slice 40 | if (value === undefined) { 41 | const {[keyToDelete]: v, ...newS} = s as any; 42 | return newS; 43 | } 44 | // update state 45 | return ({...s, ...c}); 46 | }, {}), 47 | takeUntil(this.onDestroy$), 48 | publishReplay(1) 49 | ); 50 | 51 | constructor() { 52 | // the local state service's `state$` observable should be hot on instantiation 53 | const subscription = (this.state$ as ConnectableObservable).connect(); 54 | subscription.add((this.effect$ as ConnectableObservable).connect()); 55 | 56 | this.onDestroy$.subscribe(_ => subscription.unsubscribe()); 57 | } 58 | 59 | // @TODO What if we select a state that is not given? 60 | select(mapFn: (state: T) => K): Observable { 61 | return this.state$.pipe(map(mapFn)) 62 | } 63 | 64 | connectSlices(slices: { [key: string]: Observable }): void { 65 | // @TODO validation / typing params 66 | // @TODO consider multiple observables for the same key. Here I would suggest last one wins => switchAll 67 | Object.entries(slices) 68 | .map(([slice, state$]) => state$ 69 | .pipe(map(state => ({[slice]: state}))) 70 | ) 71 | .forEach(slice$ => this.commandObservable$$.next(slice$)); 72 | } 73 | 74 | // @TODO implement key values to override effects 75 | connectEffects(effects: { [key: string]: Observable }): void { 76 | // @TODO validation / typing params 77 | Object.entries(effects) 78 | .map(([name, effect$]) => effect$) 79 | //.pipe(map(state => ({[name]: state}))) 80 | .forEach(effect$ => this.effectObservable$$.next(effect$)); 81 | } 82 | 83 | ngOnDestroy(): void { 84 | this.onDestroy$.next(true); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/app/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local-state.service'; 2 | export * from './local-effects.service'; 3 | export * from './logger.service'; 4 | export {createUnsortedStateAdapter} from './primitive-entity-adapter' 5 | -------------------------------------------------------------------------------- /src/app/common/local-effects.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, OnDestroy} from '@angular/core'; 2 | import {ConnectableObservable, Observable, Subject, Subscription} from 'rxjs'; 3 | import {mergeAll, publishReplay} from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class LocalEffects implements OnDestroy { 7 | private subscription: Subscription; 8 | private effectSubject = new Subject>(); 9 | constructor() { 10 | this.subscription = this.effectSubject 11 | .pipe(mergeAll()).subscribe(); 12 | } 13 | connectEffect(o: Observable): void { 14 | this.effectSubject.next(o); 15 | } 16 | 17 | ngOnDestroy(): void { 18 | this.subscription.unsubscribe(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/common/local-state.service.ts: -------------------------------------------------------------------------------- 1 | import {ConnectableObservable, merge, Observable, OperatorFunction, pipe, Subject, Subscription} from 'rxjs'; 2 | import {distinctUntilChanged, filter, mergeAll, pluck, publishReplay, scan, shareReplay} from 'rxjs/operators'; 3 | import {Injectable, OnDestroy} from "@angular/core"; 4 | 5 | export const stateAccumulator = (state, command): { [key: string]: any } => ({...state, ...command}); 6 | 7 | 8 | @Injectable() 9 | export class LocalState implements OnDestroy { 10 | private stateAccumulator = stateAccumulator; 11 | private _subscription = new Subscription(); 12 | private _stateObservables = new Subject>>(); 13 | private _effectSubject = new Subject>(); 14 | private _stateSlices = new Subject>(); 15 | private _state$: Observable> = 16 | merge( 17 | this._stateObservables.pipe(mergeAll()), 18 | this._stateSlices 19 | ) 20 | .pipe( 21 | scan(this.stateAccumulator, {}), 22 | publishReplay(1) 23 | ); 24 | 25 | constructor() { 26 | this._subscription.add((this._state$ as ConnectableObservable).connect()); 27 | this._subscription.add(this._effectSubject 28 | .pipe(mergeAll()).subscribe() 29 | ); 30 | } 31 | 32 | /* 33 | * connectEffect(o: Observable): void 34 | * 35 | * * @example 36 | * const ls = new LocalState<{test: string, bar: number}>(); 37 | * // Error 38 | * // ls.connectEffect(7); 39 | * ls.connectEffect(of()); 40 | * ls.connectEffect(interval(1000).pipe(tap(console.log)); 41 | * */ 42 | connectEffect(o: Observable): void { 43 | this._effectSubject.next(o); 44 | } 45 | 46 | /** 47 | * setSlice(s: Partial) => void 48 | * 49 | * @param s: Partial 50 | * 51 | * @example 52 | * const ls = new LocalState<{test: string, bar: number}>(); 53 | * // Error 54 | * // ls.setSlice({test: 7}); 55 | * ls.setSlice({test: 'tau'}); 56 | * // Error 57 | * // ls.setSlice({bar: 'tau'}); 58 | * ls.setSlice({bar: 7}); 59 | */ 60 | setState(s: Partial): void { 61 | this._stateSlices.next(s); 62 | } 63 | 64 | 65 | /** 66 | * connectSlice(o: Observable>) => void 67 | * 68 | * @param o: Observable> 69 | * 70 | * @example 71 | * const ls = new LocalState<{test: string, bar: number}>(); 72 | * // Error 73 | * // ls.connectSlice(of(7)); 74 | * // ls.connectSlice(of('tau')); 75 | * ls.connectSlice(of()); 76 | * // Error 77 | * // ls.connectSlice(of({test: 7})); 78 | * ls.connectSlice(of({test: 'tau'})); 79 | * // Error 80 | * // ls.connectSlice(of({bar: 'tau'})); 81 | * ls.connectSlice(of({bar: 7})); 82 | * 83 | * @TODO implement SliceConfig to end a stream automatically with undefined => cleanup of sate 84 | */ 85 | connectState(o: Observable>): void { 86 | this._stateObservables.next(o); 87 | } 88 | 89 | /** 90 | * select(operator?: OperatorFunction): Observable 91 | * 92 | * @param operatorOrPath?: OperatorFunction 93 | * 94 | * @example 95 | * const ls = new LocalState<{test: string, bar: number}>(); 96 | * ls.select(); 97 | * // Error 98 | * // ls.select('foo'); 99 | * ls.select('test'); 100 | * // Error 101 | * // ls.select(of(7)); 102 | * ls.select(mapTo(7)); 103 | * // Error 104 | * // ls.select(map(s => s.foo)); 105 | * ls.select(map(s => s.test)); 106 | * // Error 107 | * // ls.select(pipe()); 108 | * // ls.select(pipe(map(s => s.test), startWith(7))); 109 | * ls.select(pipe(map(s => s.test), startWith('unknown test value'))); 110 | */ 111 | // For undefined arguments i.e select() 112 | // select(operator?: K): Observable; 113 | select(operatorOrPath?: OperatorFunction): Observable; 114 | // For OperatorFunction i.e. pipe(map(s => s.slice)), map(s => s.slice) or mapTo('value') 115 | select(operatorOrPath: K): Observable; 116 | select(operatorOrPath: OperatorFunction): Observable { 117 | let oprs: OperatorFunction = pipe() as OperatorFunction; 118 | if (typeof operatorOrPath === 'string') { 119 | const path: string = operatorOrPath; 120 | oprs = pipe(pluck(...path.split('.'))); 121 | } else if (typeof operatorOrPath === 'function') { 122 | oprs = operatorOrPath 123 | } 124 | 125 | return this._state$ 126 | .pipe( 127 | // We need to accept operators to enable composition of local scope related observables 128 | // createSelector 129 | oprs, 130 | // @TODO how to deal with undefined values? 131 | // map(state => state.property) can return undefined if not set. 132 | // This leads to unwanted behaviour in views. 133 | // Should filter out undefined values be done here? 134 | filter(v => v !== undefined), 135 | // State should get pushed only if changed. as this is a repetitive task we do it here 136 | distinctUntilChanged(), 137 | // I don't want to run the same computation for multiple subscribers. 138 | // Therefore we share the computed value 139 | shareReplay(1) 140 | ); 141 | } 142 | 143 | /** 144 | * ngOnDestroy(): void 145 | * 146 | * When called it teardown all internal logic 147 | * used to connect to the `OnDestroy` life-cycle hook of services, components, directives, pipes 148 | */ 149 | ngOnDestroy(): void { 150 | this._subscription.unsubscribe(); 151 | } 152 | 153 | } 154 | -------------------------------------------------------------------------------- /src/app/data-access/github/+state/actions.ts: -------------------------------------------------------------------------------- 1 | import {createAction, props} from '@ngrx/store'; 2 | import {RepositoryListItem} from "@data-access/github"; 3 | 4 | export const fetchRepositoryList = createAction( 5 | '[Repository List] Fetch', 6 | props<{ params?: { [key: string]: string } }>() 7 | ); 8 | export const repositoryListFetchError = createAction( 9 | '[Repository List] FetchError', 10 | props<{ error: string }>() 11 | ); 12 | 13 | export const repositoryListFetchSuccess = createAction( 14 | '[Repository List] FetchSuccess', 15 | props<{ list: RepositoryListItem[] }>() 16 | ); 17 | -------------------------------------------------------------------------------- /src/app/data-access/github/+state/effects.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {Actions, createEffect, ofType} from '@ngrx/effects'; 3 | import {of} from 'rxjs'; 4 | import {catchError, map, switchMap, tap} from 'rxjs/operators'; 5 | 6 | import {GitHubService} from '../github.service'; 7 | import {fetchRepositoryList, repositoryListFetchError, repositoryListFetchSuccess} from './actions'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class GitHubEffects { 13 | 14 | fetchGithubRepositoriesList$ = createEffect(() => 15 | this.actions$.pipe( 16 | ofType(fetchRepositoryList.type), 17 | switchMap(action => 18 | this.gitHubService.getData(action).pipe( 19 | tap(v => console.log('EFFECT fetch Data', v)), 20 | map(list => repositoryListFetchSuccess({list})), 21 | catchError(error => of(repositoryListFetchError({error}))) 22 | ) 23 | ) 24 | ) 25 | ); 26 | 27 | constructor(private actions$: Actions, private gitHubService: GitHubService) { 28 | 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/app/data-access/github/+state/reducer.ts: -------------------------------------------------------------------------------- 1 | import {createReducer, on} from '@ngrx/store'; 2 | import {repositoryListFetchSuccess} from './actions'; 3 | import {RepositoryListItem} from './repository-list.model'; 4 | import {getData} from '../github.service'; 5 | 6 | export const GITHUB_FEATURE_KEY = 'github'; 7 | 8 | export interface GitHubState { 9 | user: string, 10 | list: RepositoryListItem[] 11 | } 12 | const initialGitHubState = { 13 | user: 'ReactiveX', 14 | list: getData() 15 | }; 16 | 17 | export interface GitHubFeatureState { 18 | [GITHUB_FEATURE_KEY]: GitHubState 19 | } 20 | 21 | const _gitHubReducer = createReducer( 22 | initialGitHubState, 23 | on(repositoryListFetchSuccess, (state, action) => ({ 24 | ...state, 25 | list: uniteItemArrays(state.list, action.list) 26 | }) 27 | ) 28 | ); 29 | 30 | export const gitHubReducer = (state, action) => _gitHubReducer(state, action); 31 | 32 | function uniteItemArrays(...arrs: RepositoryListItem[][]) { 33 | return Array.from( 34 | new Map(arrs 35 | .reduce((arr: any, a: any): any => arr.concat(a), []) 36 | .map(i => [i.id, i]) 37 | ).entries() 38 | ).map(e => e[1]) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/data-access/github/+state/repository-list.model.ts: -------------------------------------------------------------------------------- 1 | export interface RepositoryListItem { 2 | id: string, 3 | name: string, 4 | created: string 5 | } 6 | -------------------------------------------------------------------------------- /src/app/data-access/github/+state/selectors.ts: -------------------------------------------------------------------------------- 1 | import {createSelector} from '@ngrx/store'; 2 | import {GitHubFeatureState, GitHubState} from './reducer'; 3 | 4 | export const selectGitHub = (globalState: GitHubFeatureState) => { 5 | return globalState.github; 6 | }; 7 | 8 | export const selectRepositoryList = createSelector( 9 | selectGitHub, 10 | (state: GitHubState) => state.list 11 | ); 12 | -------------------------------------------------------------------------------- /src/app/data-access/github/github.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {StoreModule} from '@ngrx/store'; 3 | import {EffectsModule} from '@ngrx/effects'; 4 | import {GitHubEffects} from "./+state/effects"; 5 | import {GITHUB_FEATURE_KEY, gitHubReducer} from "./+state/reducer"; 6 | 7 | @NgModule({ 8 | imports: [ 9 | StoreModule.forFeature(GITHUB_FEATURE_KEY, gitHubReducer), 10 | EffectsModule.forFeature([GitHubEffects]), 11 | ], 12 | declarations: [], 13 | bootstrap: [] 14 | }) 15 | export class GithubModule { 16 | 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/app/data-access/github/github.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {HttpClient} from '@angular/common/http'; 3 | import {of} from 'rxjs'; 4 | import {RepositoryListItem} from "./+state/repository-list.model"; 5 | import {delay} from "rxjs/operators"; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class GitHubService { 11 | 12 | constructor(private http: HttpClient) { 13 | } 14 | 15 | getData = (arg?: any) => of(getData(arg)).pipe(delay(~~(Math.random()*5000))); 16 | 17 | } 18 | 19 | export function getData(cfg = {num: 5}): RepositoryListItem[] { 20 | const randId = (s: string) => s + ~~(Math.random() * 100); 21 | return new Array(cfg.num) 22 | .fill(cfg.num) 23 | .map(_ => ({ 24 | id: randId('id'), 25 | name: randId('name'), 26 | created: Date.now() / 1000 + '' 27 | })); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/data-access/github/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github.service'; 2 | export * from './+state/selectors'; 3 | export * from './+state/actions'; 4 | 5 | 6 | export {GitHubState, GITHUB_FEATURE_KEY, GitHubFeatureState} from './+state/reducer'; 7 | export * from './+state/repository-list.model' 8 | 9 | export * from './github.module'; 10 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/1/demo-basics-1.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 2 | import {Action, Store} from "@ngrx/store"; 3 | import { 4 | fetchRepositoryList, 5 | repositoryListFetchError, 6 | repositoryListFetchSuccess, 7 | RepositoryListItem 8 | } from "@data-access/github"; 9 | import {Observable, Subject} from "rxjs"; 10 | import {DemoBasicsItem} from "../demo-basics-item.interface"; 11 | import {LocalState} from "../rx-ephemeral-state"; 12 | import {ofType} from "@ngrx/effects"; 13 | import {map} from "rxjs/operators"; 14 | 15 | interface ComponentState { 16 | refreshInterval: number; 17 | list: DemoBasicsItem[]; 18 | listExpanded: boolean; 19 | } 20 | 21 | // The initial state is normally derived form somewhere else automatically. But could also get specified statically here. 22 | const initComponentState = { 23 | refreshInterval: 10000, 24 | listExpanded: false, 25 | list: [] 26 | }; 27 | 28 | @Component({ 29 | selector: 'demo-basics-1', 30 | template: ` 31 |

Demo Basic 1 - Setup and Retrieving State

32 | 33 | 36 | 37 | 38 | 39 | 40 | List 41 | 42 | 43 | {{m.list.length}} 44 | Repositories Updated every: {{m.refreshInterval}} 45 | ms 46 | 47 | {{m.list.length}} 48 | 49 | 50 | 51 | 55 | 56 |
57 | 58 | 59 | {{item.name}} 60 | 61 | 62 |
63 | 64 | 65 | No list given! 66 | 67 | 68 |
69 | `, 70 | styles: [` 71 | .list .mat-expansion-panel-header { 72 | position: relative; 73 | } 74 | 75 | .list .mat-expansion-panel-header mat-progress-bar { 76 | position: absolute; 77 | top: 0px; 78 | left: 0; 79 | } 80 | 81 | .list .mat-expansion-panel-content .mat-expansion-panel-body { 82 | padding-top: 10px; 83 | } 84 | `], 85 | changeDetection: ChangeDetectionStrategy.OnPush 86 | }) 87 | // 1) implement LocalState Service => ComponentState 88 | export class DemoBasicsComponent1 extends LocalState { 89 | 90 | // UI interaction 91 | refreshClicks = new Subject(); 92 | listExpandedChanges = new Subject(); 93 | 94 | // UI state 95 | // 1.1) derivation 96 | m = initComponentState; 97 | 98 | _refreshInterval: number; 99 | @Input() 100 | set refreshInterval(refreshInterval: number) { 101 | if (refreshInterval > 4000) { 102 | this._refreshInterval = refreshInterval; 103 | } 104 | } 105 | 106 | constructor(private store: Store) { 107 | super(); 108 | // 2.1) Optional - set initial state 109 | // 2.2) Connect input bindings 110 | // 2.3) Connect state from child components ( listExpandedChanges => listExpanded ) 111 | // 2.4) Connect Global state (selectRepositoryList -> parseListItems => list) 112 | } 113 | 114 | // Map RepositoryListItem to ListItem 115 | parseListItems(l: RepositoryListItem[]): DemoBasicsItem[] { 116 | return l.map(({id, name}) => ({id, name})) 117 | } 118 | 119 | toIsPending(o: Observable): Observable { 120 | return o.pipe( 121 | ofType(repositoryListFetchError, repositoryListFetchSuccess, fetchRepositoryList), 122 | map((a: Action) => a.type === fetchRepositoryList.type) 123 | ); 124 | } 125 | /* 126 | toIsPending(o: Observable): Observable { 127 | return o.pipe( 128 | ofType(repositoryListFetchError, repositoryListFetchSuccess, fetchRepositoryList), 129 | map((a: Action) => a.type === fetchRepositoryList.type) 130 | ); 131 | } 132 | 133 | refreshListSideEffect$ = merge( 134 | this.refreshClicks, 135 | this.select( 136 | map(s => s.refreshInterval), 137 | switchMap(ms => timer(0, ms)) 138 | ) 139 | ) 140 | .pipe( 141 | tap(_ => this.store.dispatch(fetchRepositoryList({}))) 142 | ); 143 | */ 144 | } 145 | 146 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/2/demo-basics-2.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 2 | import {Store} from "@ngrx/store"; 3 | import {map} from "rxjs/operators"; 4 | import {RepositoryListItem, selectRepositoryList} from "@data-access/github"; 5 | import {NEVER, Subject} from "rxjs"; 6 | import {DemoBasicsItem} from "../demo-basics-item.interface"; 7 | import {LocalState} from "../rx-ephemeral-state"; 8 | 9 | interface ComponentState { 10 | refreshInterval: number; 11 | list: DemoBasicsItem[]; 12 | listExpanded: boolean; 13 | } 14 | 15 | // The initial state is normally derived form somewhere else automatically. But could also get specified statically here. 16 | const initComponentState = { 17 | refreshInterval: 10000, 18 | listExpanded: false, 19 | list: [] 20 | }; 21 | 22 | @Component({ 23 | selector: 'demo-basics-2', 24 | template: ` 25 |

Demo Basics 2 - Handle Side Effects

26 | 30 | 31 | 32 | List 33 | 34 | 35 | {{m.list.length}} Repositories Updated every: {{m.refreshInterval}} 36 | ms 37 | {{m.list.length}} 38 | 39 | 40 | 41 | 42 | 46 | 47 |
48 | 49 | 50 | {{item.name}} 51 | 52 | 53 |
54 | 55 | 56 | No list given! 57 | 58 | 59 |
60 | `, 61 | changeDetection: ChangeDetectionStrategy.OnPush 62 | }) 63 | export class DemoBasicsComponent2 extends LocalState { 64 | refreshClicks = new Subject(); 65 | listExpandedChanges = new Subject(); 66 | 67 | model$ = this.select(); 68 | 69 | @Input() 70 | set refreshInterval(refreshInterval: number) { 71 | if (refreshInterval > 100) { 72 | this.setState({refreshInterval}); 73 | } 74 | } 75 | 76 | refreshListSideEffect$ = NEVER; 77 | 78 | constructor(private store: Store) { 79 | super(); 80 | this.setState(initComponentState); 81 | this.connectState(this.listExpandedChanges 82 | .pipe(map(b => ({listExpanded: b})))); 83 | this.connectState('list', 84 | this.store.select(selectRepositoryList).pipe(map(this.parseListItems)) 85 | ); 86 | 87 | // Side-Effects 88 | // 1) setup side-effect 89 | // 2) show subscribe and connect 90 | // 3) extent side effect with refresh interval 91 | this.refreshListSideEffect$.subscribe(); 92 | } 93 | 94 | parseListItems(l: RepositoryListItem[]): DemoBasicsItem[] { 95 | return l.map(({id, name}) => ({id, name})) 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/3/demo-basics-3.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 2 | import {Store} from "@ngrx/store"; 3 | import {map, switchMap, tap} from "rxjs/operators"; 4 | import {fetchRepositoryList, RepositoryListItem, selectRepositoryList} from "@data-access/github"; 5 | import {merge, Subject, timer} from "rxjs"; 6 | import {DemoBasicsItem} from "../demo-basics-item.interface"; 7 | import {LocalState} from "../rx-ephemeral-state"; 8 | 9 | interface ComponentState { 10 | refreshInterval: number; 11 | list: DemoBasicsItem[]; 12 | listExpanded: boolean; 13 | } 14 | 15 | // The initial state is normally derived form somewhere else automatically. But could also get specified statically here. 16 | const initComponentState = { 17 | refreshInterval: 10000, 18 | listExpanded: false, 19 | list: [] 20 | }; 21 | 22 | // 1. Create an interface DemoBasicsView and implement all UI interaction like buttons etc. 23 | // 2. Create an interface DemoBasicsBaseModel this is basically a copy of your previous ComponentState. 24 | // 3. Implement a property `baseModel$: Observable;` to provide the base model state. 25 | // 4. Create a service called DemoBasicsViewModel 26 | // - extend LocalState 27 | // - implement DemoBasicsView 28 | @Component({ 29 | selector: 'demo-basics-3', 30 | template: ` 31 |

Demo Basics 3 - Introduce MVVM Architecture

32 | 36 | 37 | 38 | User Name 39 | 40 | 41 | {{m.list.length}} Repositories Updated every: {{m.refreshInterval}} 42 | ms 43 | {{m.list.length}} 44 | 45 | 46 | 47 | 51 | 52 |
53 | 54 | 55 | {{item.name}} 56 | 57 | 58 |
59 | 60 | 61 | No list given! 62 | 63 | 64 |
65 | `, 66 | changeDetection: ChangeDetectionStrategy.OnPush 67 | }) 68 | export class DemoBasicsComponent3 extends LocalState { 69 | refreshClicks = new Subject(); 70 | listExpandedChanges = new Subject(); 71 | 72 | model$ = this.select(); 73 | 74 | @Input() 75 | set refreshInterval(refreshInterval: number) { 76 | if (refreshInterval > 100) { 77 | // 6. Refactor to use the vm.setState 78 | this.setState({refreshInterval}); 79 | } 80 | } 81 | 82 | refreshListSideEffect$ = merge( 83 | this.refreshClicks, 84 | this.select( 85 | map(s => s.refreshInterval), 86 | tap(console.log), 87 | switchMap(ms => timer(0, ms)) 88 | ) 89 | ) 90 | .pipe( 91 | tap(_ => this.store.dispatch(fetchRepositoryList({}))) 92 | ); 93 | 94 | // 5. Inject `DemoBasicsViewModel` as service into `DemoBasicsComponent` constructor under property `vm` 95 | constructor(private store: Store) { 96 | // remove everything related to the view 97 | super(); 98 | this.setState(initComponentState); 99 | this.connectState(this.listExpandedChanges 100 | .pipe(map(b => ({listExpanded: b})))); 101 | // Refactor to use the vm connectState method 102 | this.connectState('list', 103 | this.store.select(selectRepositoryList).pipe(map(this.parseListItems)) 104 | ); 105 | // Refactor to use the vm refreshListSideEffect$ property 106 | this.connectEffect(this.refreshListSideEffect$); 107 | } 108 | 109 | parseListItems(l: RepositoryListItem[]): DemoBasicsItem[] { 110 | return l.map(({id, name}) => ({id, name})) 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/4/demo-basics-4.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 2 | import {Store} from "@ngrx/store"; 3 | import {map, tap} from "rxjs/operators"; 4 | import { 5 | fetchRepositoryList, 6 | repositoryListFetchError, 7 | repositoryListFetchSuccess, 8 | RepositoryListItem, 9 | selectRepositoryList 10 | } from "@data-access/github"; 11 | import {DemoBasicsViewModelService} from "./demo-basics.view-model.service"; 12 | import {DemoBasicsItem} from "../demo-basics-item.interface"; 13 | import {Actions, ofType} from "@ngrx/effects"; 14 | 15 | @Component({ 16 | selector: 'demo-basics-4', 17 | templateUrl: './demo-basics-4.view.html', 18 | styles: [` 19 | .list .mat-expansion-panel-header { 20 | position: relative; 21 | } 22 | .list .mat-expansion-panel-header mat-progress-bar { 23 | position: absolute; 24 | top: 0px; 25 | left: 0; 26 | } 27 | 28 | .list .mat-expansion-panel-content .mat-expansion-panel-body { 29 | padding-top: 10px; 30 | } 31 | `], 32 | changeDetection: ChangeDetectionStrategy.OnPush, 33 | providers: [DemoBasicsViewModelService] 34 | }) 35 | export class DemoBasicsComponent4 { 36 | 37 | @Input() 38 | set refreshInterval(refreshInterval: number) { 39 | if (refreshInterval > 4000) { 40 | this.vm.setState({refreshInterval}); 41 | } 42 | } 43 | 44 | constructor(public vm: DemoBasicsViewModelService, 45 | private store: Store, 46 | private actions$: Actions) { 47 | this.vm.connectState('list', 48 | this.store.select(selectRepositoryList).pipe(map(this.parseListItems)) 49 | ); 50 | this.vm.connectEffect(this.vm.refreshListSideEffect$ 51 | .pipe(tap(_ => this.store.dispatch(fetchRepositoryList({})))) 52 | ); 53 | this.vm.connectState('isPending', this.actions$ 54 | .pipe( 55 | ofType(repositoryListFetchError, repositoryListFetchSuccess, fetchRepositoryList), 56 | map(a => a.type === fetchRepositoryList.type) 57 | )); 58 | } 59 | 60 | 61 | parseListItems(l: RepositoryListItem[]): DemoBasicsItem[] { 62 | return l.map(({id, name}) => ({id, name})) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/4/demo-basics-4.view.html: -------------------------------------------------------------------------------- 1 |

Final Example

2 | 7 | 8 | 9 | User Name 10 | 11 | {{bm.list.length}} 12 | Repositories Updated every: {{bm.refreshInterval}} 13 | ms 14 | 15 | {{bm.list.length}} 16 | 17 | 18 | 19 | 20 | 24 | 25 |
26 | 27 | 28 | {{item.name}} 29 | 30 | 31 |
32 | 33 | 34 | No list given! 35 | 36 | 37 |
38 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/4/demo-basics.base-model.interface.ts: -------------------------------------------------------------------------------- 1 | import {DemoBasicsItem} from "../demo-basics-item.interface"; 2 | 3 | export interface DemoBasicsBaseModel { 4 | refreshInterval: number; 5 | list: DemoBasicsItem[]; 6 | listExpanded: boolean; 7 | isPending: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/4/demo-basics.view-model.service.ts: -------------------------------------------------------------------------------- 1 | import {merge, Subject, timer} from "rxjs"; 2 | import {map, switchMap} from "rxjs/operators"; 3 | import {Injectable} from "@angular/core"; 4 | import {LocalState} from "../rx-ephemeral-state"; 5 | import {DemoBasicsBaseModel} from "./demo-basics.base-model.interface"; 6 | import {DemoBasicsView} from "./demo-basics.view.interface"; 7 | 8 | const initState: DemoBasicsBaseModel = { 9 | refreshInterval: 1000, 10 | listExpanded: true, 11 | isPending: true, 12 | list: [] 13 | }; 14 | 15 | @Injectable() 16 | export class DemoBasicsViewModelService extends LocalState implements DemoBasicsView { 17 | baseModel$ = this.select(); 18 | 19 | // ListView ================================================= 20 | refreshClicks = new Subject(); 21 | listExpandedChanges = new Subject(); 22 | 23 | refreshListSideEffect$ = merge( 24 | this.refreshClicks, 25 | this.select(map(s => s.refreshInterval)) 26 | .pipe(switchMap(ms => timer(ms))) 27 | ); 28 | 29 | constructor() { 30 | super(); 31 | this.setState(initState); 32 | 33 | this.connectState(this.listExpandedChanges 34 | .pipe(map(b => ({listExpanded: b}))) 35 | ); 36 | } 37 | 38 | } 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/4/demo-basics.view.interface.ts: -------------------------------------------------------------------------------- 1 | import {Observable, Subject} from "rxjs"; 2 | import {DemoBasicsBaseModel} from "./demo-basics.base-model.interface"; 3 | 4 | export interface DemoBasicsView { 5 | // All UI-Events or component EventBindings 6 | refreshClicks: Subject; 7 | listExpandedChanges: Subject 8 | // Optional The base model as observable 9 | baseModel$: Observable; 10 | // Optional Derivations as observable 11 | // .... 12 | } 13 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/demo-basics-item.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DemoBasicsItem { 2 | id: string; 3 | name:string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/demo-basics.container.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {Subject} from "rxjs"; 3 | import {map} from "rxjs/operators"; 4 | 5 | @Component({ 6 | selector: 'demo-basics-container', 7 | template: ` 8 |

Demo Basics Container

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 27 | ` 28 | }) 29 | export class DemoBasicsContainerComponent { 30 | refreshIntervalInput$ = new Subject(); 31 | refreshInterval$ = this.refreshIntervalInput$ 32 | .pipe(map((e: any) => e.target.value)); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/demo-basics.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import { 4 | MatBadgeModule, 5 | MatButtonModule, 6 | MatCardModule, 7 | MatExpansionModule, 8 | MatFormFieldModule, 9 | MatIconModule, 10 | MatInputModule, 11 | MatListModule, 12 | MatProgressBarModule, 13 | MatProgressSpinnerModule 14 | } from "@angular/material"; 15 | import {DemoBasicsComponent1} from "./1/demo-basics-1.component"; 16 | import {DemoBasicsComponent2} from "./2/demo-basics-2.component"; 17 | import {DemoBasicsComponent3} from "./3/demo-basics-3.component"; 18 | import {DemoBasicsComponent4} from "./4/demo-basics-4.component"; 19 | import {DemoBasicsContainerComponent} from "./demo-basics.container.component"; 20 | 21 | export const ROUTES = [ 22 | { 23 | path: '', 24 | component: DemoBasicsContainerComponent 25 | } 26 | ]; 27 | const DECLARATIONS = [ 28 | DemoBasicsContainerComponent, 29 | DemoBasicsComponent1, 30 | DemoBasicsComponent2, 31 | DemoBasicsComponent3, 32 | DemoBasicsComponent4 33 | ]; 34 | export const materialModules = [ 35 | MatIconModule, 36 | MatListModule, 37 | MatCardModule, 38 | MatButtonModule, 39 | MatExpansionModule, 40 | MatBadgeModule, 41 | MatProgressSpinnerModule, 42 | MatProgressBarModule, 43 | MatInputModule, 44 | MatFormFieldModule 45 | ]; 46 | 47 | @NgModule({ 48 | declarations: [DECLARATIONS], 49 | imports: [ 50 | CommonModule, 51 | materialModules 52 | ], 53 | exports: [DECLARATIONS] 54 | }) 55 | export class DemoBasicsModule { 56 | } 57 | -------------------------------------------------------------------------------- /src/app/examples/demo-basics/rx-ephemeral-state.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, OnDestroy} from '@angular/core'; 2 | import { 3 | ConnectableObservable, 4 | merge, 5 | noop, 6 | Observable, 7 | OperatorFunction, 8 | pipe, 9 | queueScheduler, 10 | Subject, 11 | Subscription, 12 | UnaryFunction 13 | } from 'rxjs'; 14 | import {distinctUntilChanged, filter, map, mergeAll, observeOn, publishReplay, scan, shareReplay} from 'rxjs/operators'; 15 | 16 | /** RxJS INTERNAL */ 17 | function pipeFromArray(fns: Array>): UnaryFunction { 18 | if (!fns) { 19 | return noop as UnaryFunction; 20 | } 21 | 22 | if (fns.length === 1) { 23 | return fns[0]; 24 | } 25 | 26 | return function piped(input: T): R { 27 | return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any); 28 | }; 29 | } 30 | 31 | export function select(): UnaryFunction; 32 | export function select(op: OperatorFunction): UnaryFunction; 33 | export function select(op1: OperatorFunction, op2: OperatorFunction): UnaryFunction; 34 | // tslint:disable-next-line:max-line-length 35 | export function select(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): UnaryFunction; 36 | // tslint:disable-next-line:max-line-length 37 | export function select(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction): UnaryFunction; 38 | export function select(...ops: OperatorFunction[]) { 39 | return pipe( 40 | pipeFromArray(ops), 41 | filter(v => v !== undefined), 42 | distinctUntilChanged(), 43 | shareReplay(1) 44 | ); 45 | } 46 | 47 | /* 48 | interface AbstractLocalState { 49 | setState(s: Partial): void; 50 | 51 | connectState(o: Observable>): void; 52 | 53 | connectEffect(o: Observable): void; 54 | 55 | select(): Observable; 56 | select(op: OperatorFunction): Observable; 57 | select(op1: OperatorFunction, op2: OperatorFunction): Observable; 58 | select(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable; 59 | select(path: A): Observable; 60 | select(...opOrMapFn: OperatorFunction[] | string[]): Observable; 61 | 62 | teardown(): void; 63 | } 64 | */ 65 | export const stateAccumulator = (acc, command): { [key: string]: number } => ({...acc, ...command}); 66 | // @TODO use accumulator with cleanup logic for undefined state slices 67 | export const deleteUndefinedStateAccumulator = (state, [keyToDelete, value]: [string, number]): { [key: string]: number } => { 68 | const isKeyToDeletePresent = keyToDelete in state; 69 | // The key you want to delete is not stored :) 70 | if (!isKeyToDeletePresent && value === undefined) { 71 | return state; 72 | } 73 | // Delete slice 74 | if (value === undefined) { 75 | const {[keyToDelete]: v, ...newS} = state as any; 76 | return newS; 77 | } 78 | // update state 79 | return ({...state, [keyToDelete]: value}); 80 | }; 81 | 82 | @Injectable() 83 | export class LocalState implements OnDestroy { 84 | private _subscription = new Subscription(); 85 | private _stateObservables = new Subject>>(); 86 | private _stateSlices = new Subject>(); 87 | private _effectSubject = new Subject(); 88 | 89 | private stateAccumulator = (acc: T, command: Partial): T => ({...acc, ...command}); 90 | 91 | // tslint:disable-next-line:member-ordering 92 | private _state$ = merge( 93 | this._stateObservables.pipe(mergeAll(), observeOn(queueScheduler)), 94 | this._stateSlices.pipe(observeOn(queueScheduler)) 95 | ).pipe( 96 | scan(this.stateAccumulator, {} as T), 97 | publishReplay(1) 98 | ); 99 | 100 | constructor() { 101 | this._subscription.add((this._state$ as ConnectableObservable).connect()); 102 | this._subscription.add((this._effectSubject 103 | .pipe(mergeAll(), publishReplay(1) 104 | ) as ConnectableObservable).connect() 105 | ); 106 | } 107 | 108 | /** 109 | * setState(s: Partial) => void 110 | * 111 | * @param s: Partial 112 | * 113 | * @example 114 | * const ls = new LocalState<{test: string, bar: number}>(); 115 | * // Error 116 | * // ls.setState({test: 7}); 117 | * ls.setState({test: 'tau'}); 118 | * // Error 119 | * // ls.setState({bar: 'tau'}); 120 | * ls.setState({bar: 7}); 121 | */ 122 | setState(s: Partial): void { 123 | this._stateSlices.next(s); 124 | } 125 | 126 | 127 | /** 128 | * connectState(o: Observable>) => void 129 | * 130 | * @param o: Observable> 131 | * 132 | * @example 133 | * const ls = new LocalState<{test: string, bar: number}>(); 134 | * // Error 135 | * // ls.connectState(of(7)); 136 | * // ls.connectState(of('tau')); 137 | * ls.connectState(of()); 138 | * // Error 139 | * // ls.connectState(of({test: 7})); 140 | * ls.connectState(of({test: 'tau'})); 141 | * // Error 142 | * // ls.connectState(of({bar: 'tau'})); 143 | * ls.connectState(of({bar: 7})); 144 | * 145 | * @TODO implement SliceConfig to end a stream automatically with undefined => cleanup of sate 146 | */ 147 | connectState(strOrObs: A | Observable>, obs?: Observable): void { 148 | let _obs; 149 | if (typeof strOrObs === 'string') { 150 | const str: A = strOrObs; 151 | const o = obs as Observable; 152 | _obs = o.pipe( 153 | map(s => ({[str]: s})) 154 | ); 155 | } else { 156 | const ob = strOrObs as Observable>; 157 | _obs = ob; 158 | } 159 | this._stateObservables.next(_obs as Observable> | Observable); 160 | } 161 | 162 | 163 | /** 164 | * connectEffect(o: Observable) => void 165 | * 166 | * @param o: Observable 167 | * 168 | * @example 169 | * const ls = new LocalState<{test: string, bar: number}>(); 170 | * // Error 171 | * // ls.connectEffect(); 172 | * ls.connectEffect(of()); 173 | * ls.connectEffect(of().pipe(tap(n => console.log('side effect', n)))); 174 | */ 175 | connectEffect(o: Observable): void { 176 | this._effectSubject.next(o); 177 | } 178 | 179 | /** 180 | * select(operator?: OperatorFunction): Observable 181 | * 182 | * @param operator?: OperatorFunction 183 | * 184 | * @example 185 | * const ls = new LocalState<{test: string, bar: number}>(); 186 | * ls.select(); 187 | * // Error 188 | * // ls.select('foo'); 189 | * ls.select('test'); 190 | * // Error 191 | * // ls.select(of(7)); 192 | * ls.select(mapTo(7)); 193 | * // Error 194 | * // ls.select(map(s => s.foo)); 195 | * ls.select(map(s => s.test)); 196 | * // Error 197 | * // ls.select(pipe()); 198 | * // ls.select(pipe(map(s => s.test), startWith(7))); 199 | * ls.select(pipe(map(s => s.test), startWith('unknown test value'))); 200 | * @TODO consider state keys as string could be passed 201 | * // For state keys as string i.e. 'bar' 202 | select(operator?: K): Observable; 203 | if (typeof operator === 'string') { 204 | const key: string = operator; 205 | operators = pipe(map(s => operator ? s[key] : s)); 206 | } 207 | * @TODO consider ngrx selectors could be passed 208 | * // For project functions i.e. (s) => s.slice, (s) => s.slice * 2 or (s) => 2 209 | * select(operator: (value: T, index?: number) => T | R, thisArg?: any): Observable; 210 | if (typeof operator === 'function') { 211 | const mapFn: (value: T, index: number) => R = operator ? operator : (value: T, index: number): R => value; 212 | operators = pipe(map(mapFn)); 213 | } 214 | */ 215 | select(): Observable; 216 | select(op: OperatorFunction): Observable; 217 | select(op1: OperatorFunction, op2: OperatorFunction): Observable; 218 | select(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable; 219 | select(path: A): Observable; 220 | select(...opOrMapFn: OperatorFunction[] | string[]): Observable { 221 | if (!opOrMapFn || opOrMapFn.length === 0) { 222 | return this._state$ 223 | .pipe( 224 | distinctUntilChanged(), 225 | shareReplay(1) 226 | ); 227 | } else if (!this.isOperateFnArray(opOrMapFn)) { 228 | const [path] = opOrMapFn; 229 | return this._state$.pipe( 230 | map((x: T) => x[path]), 231 | filter(v => v !== undefined), 232 | distinctUntilChanged(), 233 | shareReplay(1) 234 | ); 235 | } else { 236 | return this._state$.pipe( 237 | select(...opOrMapFn as []) 238 | ); 239 | } 240 | } 241 | 242 | private isOperateFnArray(op: OperatorFunction[] | string[]): op is OperatorFunction[] { 243 | return !(op.length === 1 && typeof op[0] === 'string'); 244 | } 245 | 246 | /** 247 | * teardown(): void 248 | * 249 | * When called it teardown all internal logic 250 | * used to connect to the `OnDestroy` life-cycle hook of services, components, directives, pipes 251 | */ 252 | teardown(): void { 253 | this._subscription.unsubscribe(); 254 | } 255 | 256 | /** 257 | * ngOnDestroy(): void 258 | * 259 | * When called it teardown all internal logic 260 | * used to connect to the `OnDestroy` life-cycle hook of services, components, directives, pipes 261 | */ 262 | ngOnDestroy(): void { 263 | this.teardown(); 264 | } 265 | 266 | } 267 | -------------------------------------------------------------------------------- /src/app/examples/examples.container.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'example-container', 5 | template: ` 6 | 7 | ` 8 | }) 9 | export class ExampleContainerComponent { 10 | } 11 | -------------------------------------------------------------------------------- /src/app/examples/problems/cold-composition/cold-composition-bad.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {SomeBadService} from "./some-bad.service"; 3 | 4 | @Component({ 5 | selector: 'cold-composition-bad', 6 | template: ` 7 |

Cold Composition

8 |
9 | 10 | 11 | Cold Composition 12 | subscriber controls composition 13 | 14 | 15 | someService.composedState$: {{someBadService.composedState$ | async | json}} 16 | 17 | 18 | `, 19 | providers: [SomeBadService] 20 | }) 21 | export class ColdCompositionBadComponent { 22 | isOpen = false; 23 | 24 | constructor(public someBadService: SomeBadService) { 25 | 26 | } 27 | 28 | updateState() { 29 | this.someBadService.commands$.next({sum: 1}) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/app/examples/problems/cold-composition/cold-composition-good.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {SomeGoodService} from "./some-good.service"; 3 | 4 | @Component({ 5 | selector: 'cold-composition-good', 6 | template: ` 7 |

Hot Composition

8 |
9 | 10 | 11 | Hot Composition 12 | Source controls composition 13 | 14 | 15 | someService.composedState$: {{someGoodService.composedState$ | async | json}} 16 | 17 | 18 | `, 19 | providers: [SomeGoodService] 20 | }) 21 | export class ColdCompositionGoodComponent { 22 | isOpen = false; 23 | 24 | constructor(public someGoodService: SomeGoodService) { 25 | 26 | } 27 | 28 | updateState() { 29 | this.someGoodService.commands$.next({sum: 1}) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/app/examples/problems/cold-composition/cold-composition.container.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'cold-composition-container', 5 | template: ` 6 |

Cold Composition Problem

7 | 8 | 9 |
10 | 11 | 12 | ` 13 | }) 14 | export class ColdCompositionContainerComponent { 15 | } 16 | -------------------------------------------------------------------------------- /src/app/examples/problems/cold-composition/cold-composition.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {ColdCompositionContainerComponent} from "./cold-composition.container.component"; 4 | import {ColdCompositionBadComponent} from "./cold-composition-bad.component"; 5 | import {ColdCompositionGoodComponent} from "./cold-composition-good.component"; 6 | import {FormsModule} from "@angular/forms"; 7 | import {MatButtonModule, MatExpansionModule, MatSlideToggleModule} from "@angular/material"; 8 | 9 | const DECLARATIONS = [ColdCompositionContainerComponent, ColdCompositionBadComponent, ColdCompositionGoodComponent]; 10 | const MATERIAL_MODULES = [MatButtonModule, MatSlideToggleModule, MatExpansionModule]; 11 | export const ROUTES = [{ 12 | path: '', 13 | component: ColdCompositionContainerComponent 14 | }]; 15 | 16 | @NgModule({ 17 | declarations: [DECLARATIONS], 18 | imports: [ 19 | CommonModule, 20 | FormsModule, 21 | MATERIAL_MODULES 22 | ], 23 | exports: [DECLARATIONS] 24 | }) 25 | export class ColdCompositionModule { 26 | } 27 | -------------------------------------------------------------------------------- /src/app/examples/problems/cold-composition/some-bad.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, OnDestroy} from '@angular/core'; 2 | import {Subject} from 'rxjs'; 3 | import {scan, shareReplay, tap} from "rxjs/operators"; 4 | 5 | @Injectable() 6 | export class SomeBadService implements OnDestroy { 7 | commands$ = new Subject(); 8 | composedState$ = this.commands$ 9 | .pipe( 10 | tap(v => console.log('compute state ', v)), 11 | scan((acc, i) => { 12 | return {sum : acc['sum'] + i['sum']}; 13 | }, {sum: 0}), 14 | shareReplay({refCount: true, bufferSize: 1}) 15 | ); 16 | 17 | constructor() { 18 | 19 | } 20 | 21 | ngOnDestroy(): void { 22 | this.commands$.complete(); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/app/examples/problems/cold-composition/some-good.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, OnDestroy} from '@angular/core'; 2 | import {ConnectableObservable, Subject, Subscription} from 'rxjs'; 3 | import {publishReplay, scan, tap} from "rxjs/operators"; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class SomeGoodService implements OnDestroy { 9 | commands$ = new Subject(); 10 | serviceSubscription = new Subscription(); 11 | composedState$ = this.commands$ 12 | .pipe( 13 | tap(v => console.log('compute state ', v)), 14 | scan((acc, i) => { 15 | return {sum : acc['sum'] + i['sum']}; 16 | }, {sum: 0}), 17 | publishReplay(1) 18 | ) as ConnectableObservable; 19 | 20 | constructor() { 21 | this.serviceSubscription = this.composedState$.connect(); 22 | } 23 | 24 | ngOnDestroy(): void { 25 | this.serviceSubscription.unsubscribe(); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-interaction-bad.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {DeclarativeInteractionBadService} from "./declarative-interaction-bad.service"; 3 | 4 | @Component({ 5 | selector: 'declarative-interaction-bad', 6 | template: ` 7 |

Imperative Interaction

8 |
{{state$ | async | json}}
9 | 12 | `, 13 | 14 | providers: [DeclarativeInteractionBadService] 15 | }) 16 | export class DeclarativeInteractionBadComponent { 17 | state$ = this.stateService.state$; 18 | 19 | constructor(private stateService: DeclarativeInteractionBadService) { 20 | 21 | } 22 | 23 | updateCount() { 24 | this.stateService 25 | .dispatch(({count: ~~(Math.random() * 100)})); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-interaction-bad.service.ts: -------------------------------------------------------------------------------- 1 | import {OnDestroy} from '@angular/core'; 2 | import {ConnectableObservable, Subject, Subscription} from 'rxjs'; 3 | import {map, publishReplay, scan} from 'rxjs/operators'; 4 | 5 | const stateAccumulator = (acc, [key, value]: [string, number]): { [key: string]: number } => ({...acc, [key]: value}); 6 | 7 | export class DeclarativeInteractionBadService implements OnDestroy { 8 | private stateSubscription = new Subscription(); 9 | private stateAccumulator = stateAccumulator; 10 | 11 | private stateSubject = new Subject<{ [key: string]: number }>(); 12 | state$ = this.stateSubject 13 | .pipe( 14 | // process single state change 15 | map(obj => Object.entries(obj).pop()), 16 | scan(this.stateAccumulator, {}), 17 | publishReplay(1) 18 | ) as ConnectableObservable; 19 | 20 | 21 | constructor() { 22 | this.stateSubscription = this.state$.connect(); 23 | } 24 | 25 | ngOnDestroy(): void { 26 | this.stateSubscription.unsubscribe(); 27 | } 28 | 29 | // setter with value not compose-able 30 | dispatch(v) { 31 | this.stateSubject.next(v); 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-interaction-good.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {DeclarativeInteractionGoodService} from "./declarative-interaction-good.service"; 3 | import {Subject} from "rxjs"; 4 | import {map} from "rxjs/operators"; 5 | 6 | @Component({ 7 | selector: 'declarative-interaction-good', 8 | template: ` 9 |

Declarative Interaction

10 |
{{state$ | async | json}}
11 | 14 | `, 15 | providers: [DeclarativeInteractionGoodService] 16 | }) 17 | export class DeclarativeInteractionGoodComponent { 18 | state$ = this.stateService.state$; 19 | update$ = new Subject(); 20 | 21 | constructor(private stateService: DeclarativeInteractionGoodService) { 22 | this.stateService.connectSlice(this.update$ 23 | .pipe(map(_ => ({count: ~~(Math.random() * 100)})))); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-interaction-good.service.ts: -------------------------------------------------------------------------------- 1 | import {OnDestroy} from '@angular/core'; 2 | import {ConnectableObservable, Observable, Subject, Subscription} from 'rxjs'; 3 | import {map, mergeAll, publishReplay, scan} from 'rxjs/operators'; 4 | 5 | const stateAccumulator = (acc, [key, value]: [string, number]): { [key: string]: number } => ({...acc, [key]: value}); 6 | 7 | export class DeclarativeInteractionGoodService implements OnDestroy { 8 | private stateSubscription = new Subscription(); 9 | private stateAccumulator = stateAccumulator; 10 | private stateSubject = new Subject>(); 11 | state$ = this.stateSubject 12 | .pipe( 13 | // process observables of state changes 14 | mergeAll(), 15 | // process single state change 16 | map(obj => Object.entries(obj).pop()), 17 | scan(this.stateAccumulator, {}), 18 | publishReplay(1) 19 | ) as ConnectableObservable; 20 | 21 | 22 | constructor() { 23 | this.stateSubscription = this.state$.connect(); 24 | } 25 | 26 | 27 | 28 | ngOnDestroy(): void { 29 | this.stateSubscription.unsubscribe(); 30 | } 31 | 32 | connectSlice(o) { 33 | this.stateSubject.next(o); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-interaction.container.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'declarative-interaction-container', 5 | template: ` 6 | 7 | 8 | 9 | ` 10 | }) 11 | export class DeclarativeInteractionContainerComponent { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-interaction.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | 4 | import {DeclarativeInteractionContainerComponent} from "./declarative-interaction.container.component"; 5 | import {DeclarativeInteractionGoodComponent} from "./declarative-interaction-good.component"; 6 | import {DeclarativeInteractionBadComponent} from "./declarative-interaction-bad.component"; 7 | import {DeclarativeSideEffectsGoodComponent} from "./declarative-side-effects-good.component"; 8 | import {MatButtonModule} from "@angular/material"; 9 | 10 | export const ROUTES = [ 11 | { 12 | path: '', 13 | component: DeclarativeInteractionContainerComponent 14 | } 15 | ]; 16 | const DECLARATIONS = [ 17 | DeclarativeInteractionContainerComponent, 18 | DeclarativeInteractionGoodComponent, 19 | DeclarativeInteractionBadComponent, 20 | DeclarativeSideEffectsGoodComponent 21 | ]; 22 | 23 | @NgModule({ 24 | declarations: [DECLARATIONS], 25 | imports: [ 26 | CommonModule, 27 | MatButtonModule 28 | ], 29 | exports: [DECLARATIONS] 30 | }) 31 | export class DeclarativeInteractionModule { 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-side-effects-good.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {interval} from "rxjs"; 3 | import {tap} from "rxjs/operators"; 4 | import {DeclarativeSideEffectsGoodService} from "./declarative-side-effects-good.service"; 5 | 6 | @Component({ 7 | selector: 'declarative-side-effects-good', 8 | template: ` 9 |

Declarative SideEffects

10 | `, 11 | providers: [DeclarativeSideEffectsGoodService] 12 | }) 13 | export class DeclarativeSideEffectsGoodComponent { 14 | constructor(private stateService: DeclarativeSideEffectsGoodService) { 15 | this.stateService.connectEffect(interval(1000) 16 | .pipe(tap(_ => ({count: ~~(Math.random() * 100)})))); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/app/examples/problems/declarative-interaction/declarative-side-effects-good.service.ts: -------------------------------------------------------------------------------- 1 | import {OnDestroy} from '@angular/core'; 2 | import {ConnectableObservable, Observable, Subject, Subscription} from 'rxjs'; 3 | import {mergeAll, publishReplay} from 'rxjs/operators'; 4 | 5 | 6 | export class DeclarativeSideEffectsGoodService implements OnDestroy { 7 | private effectSubscription = new Subscription(); 8 | private effectSubject = new Subject>(); 9 | 10 | constructor() { 11 | this.effectSubscription = (this.effectSubject 12 | .pipe( 13 | // process observables of side-effects 14 | // process side-effect 15 | mergeAll()) 16 | .subscribe() 17 | ) 18 | } 19 | 20 | ngOnDestroy(): void { 21 | this.effectSubscription.unsubscribe(); 22 | } 23 | 24 | connectEffect(o) { 25 | this.effectSubject.next(o); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/app/examples/problems/late-subscriber/late-subscriber-fix.display.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 2 | import {ReplaySubject} from 'rxjs'; 3 | 4 | @Component({ 5 | selector: 'late-subscriber-fix-display', 6 | template: ` 7 |

Late Subscriber Fix Child

8 |

child state$:

9 |
{{state$ | async | json}}
10 | `, 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class LateSubscriberFixDisplayComponent { 14 | 15 | 16 | state$ = new ReplaySubject(1); 17 | 18 | constructor() { 19 | } 20 | 21 | @Input() 22 | set state(value) { 23 | this.state$.next({value}); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/app/examples/problems/late-subscriber/late-subscriber.container.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {of} from 'rxjs'; 3 | 4 | @Component({ 5 | selector: 'late-subscribers-container', 6 | template: ` 7 |

parent state$:

8 |
{{num$ | async | json}}
9 | 10 | 11 | 12 | 13 | `, 14 | changeDetection: ChangeDetectionStrategy.OnPush 15 | }) 16 | export class LateSubscribersContainerComponent { 17 | num$ = of(1); 18 | 19 | constructor() { 20 | console.log('Container Constructor') 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/app/examples/problems/late-subscriber/late-subscriber.display.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 2 | import {Subject} from 'rxjs'; 3 | 4 | @Component({ 5 | selector: 'late-subscriber-display', 6 | template: ` 7 |

Late Subscriber Child

8 |

child state$:

9 |
{{state$ | async | json}}
10 | `, 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class LateSubscriberDisplayComponent { 14 | 15 | 16 | state$ = new Subject(); 17 | 18 | constructor() { 19 | } 20 | 21 | @Input() 22 | set state(value) { 23 | this.state$.next({value}); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/app/examples/problems/late-subscriber/late-subscriber.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {LateSubscribersContainerComponent} from "./late-subscriber.container.component"; 4 | import {LateSubscriberDisplayComponent} from "./late-subscriber.display.component"; 5 | import {LateSubscriberFixDisplayComponent} from "./late-subscriber-fix.display.component"; 6 | 7 | export const ROUTES = [ 8 | { 9 | path: '', 10 | component: LateSubscribersContainerComponent 11 | } 12 | ]; 13 | const DECLARATIONS = [ 14 | LateSubscribersContainerComponent, LateSubscriberDisplayComponent, LateSubscriberFixDisplayComponent]; 15 | @NgModule({ 16 | declarations: [DECLARATIONS], 17 | imports: [ 18 | CommonModule 19 | ], 20 | exports: [DECLARATIONS] 21 | }) 22 | export class LateSubscriberModule { 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/app/examples/problems/sharing-a-reference/sharing-a-reference-bad.display.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, Output} from '@angular/core'; 2 | import {FormBuilder, FormGroup} from '@angular/forms'; 3 | import {Observable, ReplaySubject} from 'rxjs'; 4 | import {map, startWith, switchMap} from 'rxjs/operators'; 5 | 6 | @Component({ 7 | selector: 'sharing-a-reference-bad-display', 8 | template: ` 9 |

Sharing a reference Bad

10 |
11 | 12 | 13 | 14 | 15 |
16 | ` 17 | }) 18 | export class SharingAReferenceBadDisplayComponent { 19 | state$ = new ReplaySubject(1); 20 | @Input() 21 | set formGroupModel(modelFromInput: { [key: string]: any }) { 22 | if (modelFromInput) { 23 | this.state$.next(modelFromInput); 24 | } 25 | } 26 | 27 | formGroup$: Observable = this.state$ 28 | .pipe( 29 | startWith({}), 30 | map(input => this.getFormGroupFromConfig(input)) 31 | ); 32 | 33 | @Output() formValueChange = this.formGroup$ 34 | .pipe(switchMap((fg: FormGroup) => fg.valueChanges)); 35 | 36 | constructor(private fb: FormBuilder) { 37 | 38 | } 39 | 40 | getFormGroupFromConfig(modelFromInput) { 41 | const config = Object.entries(modelFromInput) 42 | .reduce((c, [name, initialValue]) => ({...c, [name]: [initialValue]}), {}); 43 | return this.fb.group(config); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/app/examples/problems/sharing-a-reference/sharing-a-reference-basics.display.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {Observable, Subject} from 'rxjs'; 3 | import {map, shareReplay} from "rxjs/operators"; 4 | 5 | @Component({ 6 | selector: 'sharing-a-reference-basics-display', 7 | template: ` 8 |

Sharing a reference Basics

9 | ` 10 | }) 11 | export class SharingAReferenceBasicsDisplayComponent { 12 | 13 | constructor() { 14 | const timeStampUni$ = new Observable((subscriber) => { 15 | console.log('create producer logic'); 16 | const dataObject = new Date(); 17 | subscriber.next(dataObject); 18 | }); 19 | 20 | timeStampUni$ 21 | .subscribe(d => console.log('date: ', d)); 22 | timeStampUni$ 23 | .subscribe(d => console.log('date: ', d)); 24 | 25 | // --- 26 | 27 | const timeStampMulti$ = new Subject(); 28 | timeStampMulti$ 29 | .subscribe(d => console.log('date: ', d)); 30 | timeStampMulti$ 31 | .subscribe(d => console.log('date: ', d)); 32 | 33 | console.log('create date object'); 34 | const dataObject = new Date(); 35 | timeStampMulti$.next(dataObject); 36 | 37 | // --- 38 | 39 | const timeStampSub$ = new Subject(); 40 | const timeStampOpr$ = timeStampSub$.pipe( 41 | map((date: Date) => { 42 | console.log('date transformation', date); 43 | return date.getTime(); 44 | }) 45 | ); 46 | 47 | timeStampOpr$ 48 | .subscribe(d => console.log('date: ', d)); 49 | timeStampOpr$ 50 | .subscribe(d => console.log('date: ', d)); 51 | 52 | console.log('create date object'); 53 | const dataObject2 = new Date(); 54 | timeStampSub$.next(dataObject2); 55 | 56 | // --- 57 | 58 | const timeStampSub1$ = new Subject(); 59 | const timeStampOpr1$ = timeStampSub1$.pipe( 60 | map((date: Date) => { 61 | console.log('date transformation', date); 62 | return date.getTime(); 63 | }), 64 | shareReplay({refCount: true, bufferSize: 1}) 65 | ); 66 | 67 | timeStampOpr1$ 68 | .subscribe(d => console.log('date: ', d)); 69 | timeStampOpr1$ 70 | .subscribe(d => console.log('date: ', d)); 71 | 72 | console.log('create date object'); 73 | const dataObject3 = new Date(); 74 | timeStampSub1$.next(dataObject3); 75 | 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/examples/problems/sharing-a-reference/sharing-a-reference-good.display.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input, Output} from '@angular/core'; 2 | import {FormBuilder, FormGroup} from '@angular/forms'; 3 | import {Observable, ReplaySubject} from 'rxjs'; 4 | import {map, shareReplay, startWith, switchMap} from 'rxjs/operators'; 5 | 6 | @Component({ 7 | selector: 'sharing-a-reference-good-display', 8 | template: ` 9 |

Sharing a reference - Good

10 |
11 | 12 | 13 | 14 | 15 |
16 | ` 17 | }) 18 | export class SharingAReferenceGoodDisplayComponent { 19 | state$ = new ReplaySubject(1); 20 | 21 | @Input() 22 | set formGroupModel(modelFromInput: { [key: string]: any }) { 23 | if (modelFromInput) { 24 | this.state$.next(modelFromInput); 25 | } 26 | } 27 | 28 | formGroup$: Observable = this.state$ 29 | .pipe( 30 | startWith({}), 31 | map(input => this.getFormGroupFromConfig(input)), 32 | shareReplay(1) 33 | ); 34 | 35 | @Output() formValueChange = this.formGroup$ 36 | .pipe(switchMap((fg: FormGroup) => fg.valueChanges)); 37 | 38 | constructor(private fb: FormBuilder) { 39 | 40 | } 41 | 42 | getFormGroupFromConfig(modelFromInput) { 43 | const config = Object.entries(modelFromInput) 44 | .reduce((c, [name, initialValue]) => ({...c, [name]: [initialValue]}), {}); 45 | return this.fb.group(config); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/examples/problems/sharing-a-reference/sharing-a-reference-imp.display.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, EventEmitter, Input, Output} from '@angular/core'; 2 | import {FormBuilder, FormGroup} from '@angular/forms'; 3 | import {Subscription} from 'rxjs'; 4 | 5 | @Component({ 6 | selector: 'sharing-a-reference-imp-display', 7 | template: ` 8 |

Sharing a reference Imperative

9 |
10 | 11 | 12 | 13 | 14 |
15 | ` 16 | }) 17 | export class SharingAReferenceImpDisplayComponent { 18 | subscription = new Subscription(); 19 | formGroup: FormGroup; 20 | 21 | @Input() 22 | set formGroupModel(modelFromInput: { [key: string]: any }) { 23 | if (modelFromInput) { 24 | this.updateFormAndOutput(modelFromInput); 25 | } 26 | } 27 | 28 | @Output() formValueChange = new EventEmitter(); 29 | 30 | constructor(private fb: FormBuilder) { 31 | this.updateFormAndOutput({}); 32 | } 33 | 34 | updateFormAndOutput(formConfig) { 35 | if (this.subscription) { 36 | this.subscription.unsubscribe(); 37 | } 38 | this.formGroup = this.getFormGroupFromConfig(formConfig); 39 | this.subscription = this.formGroup.valueChanges 40 | .subscribe(v => this.formValueChange.next(v)); 41 | } 42 | 43 | getFormGroupFromConfig(modelFromInput: { [key: string]: any }): FormGroup { 44 | const config = Object.entries(modelFromInput) 45 | .reduce((c, [name, initialValue]) => ({...c, [name]: [initialValue]}), {}); 46 | return this.fb.group(config); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/app/examples/problems/sharing-a-reference/sharing-a-reference.container.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {of, Subject} from 'rxjs'; 3 | 4 | @Component({ 5 | selector: 'sharing-a-reference-container', 6 | template: ` 7 |

formGroupModel$:

8 |
{{formGroupModel$ | async | json}}
9 |
10 |

imperative version:

11 |
{{imp$ | async | json}}
12 | 15 | 16 | 17 |
18 | 19 |

reactive bad version:

20 |
{{reactiveBad$ | async | json}}
21 | 24 | 25 |
26 |

reactiveGood$:

27 |
{{reactiveGood$ | async | json}}
28 | 31 | 32 | 33 | ` 34 | }) 35 | export class SharingAReferenceContainerComponent { 36 | 37 | imp$ = new Subject(); 38 | reactiveBad$ = new Subject(); 39 | reactiveGood$ = new Subject(); 40 | 41 | formGroupModel$ = of({ 42 | name: '', 43 | age: 0 44 | }); 45 | 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/app/examples/problems/sharing-a-reference/sharing-a-reference.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {ReactiveFormsModule} from "@angular/forms"; 4 | import {SharingAReferenceBadDisplayComponent} from "./sharing-a-reference-bad.display.component"; 5 | import {SharingAReferenceGoodDisplayComponent} from "./sharing-a-reference-good.display.component"; 6 | import {SharingAReferenceContainerComponent} from "./sharing-a-reference.container.component"; 7 | import {SharingAReferenceImpDisplayComponent} from "./sharing-a-reference-imp.display.component"; 8 | import {SharingAReferenceBasicsDisplayComponent} from "./sharing-a-reference-basics.display.component"; 9 | import {MatFormFieldModule, MatInputModule} from "@angular/material"; 10 | 11 | export const ROUTES = [ 12 | { 13 | path: '', 14 | component: SharingAReferenceContainerComponent 15 | } 16 | ]; 17 | const DECLARATIONS = [ 18 | SharingAReferenceContainerComponent, 19 | SharingAReferenceBadDisplayComponent, 20 | SharingAReferenceGoodDisplayComponent, 21 | SharingAReferenceImpDisplayComponent, 22 | SharingAReferenceBasicsDisplayComponent 23 | ]; 24 | 25 | @NgModule({ 26 | declarations: [DECLARATIONS], 27 | imports: [ 28 | CommonModule, 29 | MatInputModule, 30 | MatFormFieldModule, 31 | ReactiveFormsModule 32 | ], 33 | exports: [DECLARATIONS] 34 | }) 35 | export class SharingAReferenceModule { 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/app/examples/problems/subscription-handling/subscription-handling-bad.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, OnDestroy} from '@angular/core'; 2 | import {Subject, timer} from 'rxjs'; 3 | import {takeUntil, tap} from 'rxjs/operators'; 4 | 5 | @Component({ 6 | selector: 'subscription-handling-bad', 7 | template: ` 8 |

Subscription Handling

9 | ` 10 | }) 11 | export class SubscriptionHandlingBadComponent implements OnDestroy { 12 | onDestroy$ = new Subject(); 13 | 14 | sideEffect$ = timer(0, 1000).pipe(tap(console.log)); 15 | 16 | constructor() { 17 | this.sideEffect$ 18 | .pipe(takeUntil(this.onDestroy$)) 19 | .subscribe(); 20 | } 21 | 22 | ngOnDestroy(): void { 23 | this.onDestroy$.next(true); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/examples/problems/subscription-handling/subscription-handling.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {timer} from 'rxjs'; 3 | import {tap} from 'rxjs/operators'; 4 | import {SubscriptionHandlingService} from './subscription-handling.service'; 5 | 6 | @Component({ 7 | selector: 'subscription-handling', 8 | template: ` 9 |

Subscription Handling

10 | `, 11 | providers: [SubscriptionHandlingService] 12 | }) 13 | export class SubscriptionHandlingComponent { 14 | sideEffect$ = timer(0, 1000) 15 | .pipe(tap(console.log)); 16 | 17 | constructor(private subHandles: SubscriptionHandlingService) { 18 | this.subHandles 19 | .subscribe(this.sideEffect$) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/examples/problems/subscription-handling/subscription-handling.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {SubscriptionHandlingComponent} from "./subscription-handling.component"; 4 | 5 | export const ROUTES = [ 6 | { 7 | path: '', 8 | component: SubscriptionHandlingComponent 9 | } 10 | ]; 11 | const DECLARATIONS = [ 12 | SubscriptionHandlingComponent, SubscriptionHandlingComponent 13 | ]; 14 | @NgModule({ 15 | declarations: [DECLARATIONS], 16 | imports: [ 17 | CommonModule 18 | ], 19 | exports: [DECLARATIONS] 20 | }) 21 | export class SubscriptionHandlingModule { } 22 | -------------------------------------------------------------------------------- /src/app/examples/problems/subscription-handling/subscription-handling.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, OnDestroy} from '@angular/core'; 2 | import {Subject} from 'rxjs'; 3 | import {takeUntil} from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class SubscriptionHandlingService implements OnDestroy { 7 | 8 | onDestroy$ = new Subject(); 9 | 10 | subscribe(o): void { 11 | o.pipe(takeUntil(this.onDestroy$)) 12 | .subscribe(); 13 | } 14 | 15 | ngOnDestroy(): void { 16 | this.onDestroy$.next(); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/research-ephemeral-state-dramatic-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/src/assets/research-ephemeral-state-dramatic-title.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioPhoton/research-reactive-ephemeral-state-in-component-oriented-frontend-frameworks/581bed301fcfc939a8bb6704d9411ff581ec357c/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BlogComponentState 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {enableProdMode} from '@angular/core'; 2 | import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; 3 | 4 | import {AppModule} from './app/app.module'; 5 | import {environment} from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/styes/_general.scss: -------------------------------------------------------------------------------- 1 | body { 2 | 3 | } 4 | .row { 5 | display: flex; 6 | margin: 0 -15px; 7 | .col { 8 | width: 50%; 9 | padding: 0 15px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styes/_logger.scss: -------------------------------------------------------------------------------- 1 | .log-item { 2 | &.component { 3 | background: $mat-green; 4 | &.ctor { 5 | background: lightgreen; 6 | } 7 | &.on-changes { 8 | background: lightgreen; 9 | } 10 | &.on-init { 11 | background: lightgreen; 12 | } 13 | &.after-view-init { 14 | background: lightgreen; 15 | } 16 | &.after-view-checked { 17 | background: lightgreen; 18 | } 19 | &.after-content-init { 20 | background: lightgreen; 21 | } 22 | &.after-content-checked { 23 | background: lightgreen; 24 | } 25 | &.template-expression { 26 | background: lightgreen; 27 | } 28 | &.template-binding { 29 | background: lightgreen; 30 | } 31 | &.on-destroy { 32 | background: lightgreen; 33 | } 34 | } 35 | 36 | &.directive { 37 | background: orange; 38 | &.ctor { 39 | background: lightgreen; 40 | } 41 | &.on-changes { 42 | background: lightgreen; 43 | } 44 | &.on-init { 45 | background: lightgreen; 46 | } 47 | &.on-destroy { 48 | background: lightgreen; 49 | } 50 | } 51 | 52 | &.pipe { 53 | background: rosybrown; 54 | &.ctor { 55 | background: lightgreen; 56 | } 57 | &.transform { 58 | background: lightgreen; 59 | } 60 | &.on-destroy { 61 | background: lightgreen; 62 | } 63 | } 64 | 65 | &.service { 66 | background: lightblue; 67 | &.ctor { 68 | background: lightgreen; 69 | } 70 | &.on-destroy { 71 | background: lightgreen; 72 | } 73 | } 74 | 75 | &.module { 76 | background: gray; 77 | &.ctor { 78 | background: lightgreen; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/styes/theme.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/@angular/material/theming'; 2 | // Plus imports for other components in your app. 3 | 4 | // Include the common styles for Angular Material. We include this here so that you only 5 | // have to load a single css file for Angular Material in your app. 6 | // Be sure that you only ever include this mixin once! 7 | @include mat-core(); 8 | 9 | // Define the palettes for your theme using the Material Design palettes available in palette.scss 10 | // (imported above). For each palette, you can optionally specify a default, lighter, and darker 11 | // hue. Available color palettes: https://material.io/design/color/ 12 | $rx-state-primary: mat-palette($mat-indigo); 13 | $rx-state-accent: mat-palette($mat-pink, A200, A100, A400); 14 | 15 | // The warn palette is optional (defaults to red). 16 | $rx-state-warn: mat-palette($mat-red); 17 | 18 | // Create the theme object (a Sass map containing all of the palettes). 19 | $rx-state-theme: mat-light-theme($rx-state-primary, $rx-state-accent, $rx-state-warn); 20 | 21 | // Include theme styles for core and each component used in your app. 22 | // Alternatively, you can import and @include the theme mixins for each component 23 | // that you are using. 24 | @include angular-material-theme($rx-state-theme); 25 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 5 | 6 | @import "styes/theme"; 7 | @import "styes/general"; 8 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ], 21 | "paths": { 22 | "@data-access/github": [ 23 | "src/app/data-access/github" 24 | ], 25 | "@data-access/github/*": [ 26 | "src/app/data-access/github/" 27 | ], 28 | "@data-access/meetings": [ 29 | "src/app/data-access/meetings" 30 | ], 31 | "@data-access/meetings/*": [ 32 | "src/app/data-access/meetings/*" 33 | ], 34 | "@common": [ 35 | "src/app/common" 36 | ] 37 | } 38 | }, 39 | "angularCompilerOptions": { 40 | "fullTemplateTypeCheck": true, 41 | "strictInjectionParameters": true 42 | } 43 | } 44 | --------------------------------------------------------------------------------