State

Karine Samyn, Benjamin Vertonghen, Thomas Aelbrecht, Pieter Van Der Helst

"Deleted code is debugged code." - Jeff Sickel

overview

  1. different kinds of state
    describe 5 different kinds of state
  2. problems with state
    syncing state potentially leads to problems
  3. url vs local client state
    example of a url to local client out of order sync problem
  4. RxJS flattening operators
    rxjs flattening operators explained through examples
  5. url vs local client state fixed
    a fix using these operators for our first problem

intro

  • Today we'll talk about keeping and syncing state and all related problems and issues
  • We'll look into some more advanced RxJS operators and how to flatten higher order Observables
  • And finally we'll see how to apply those operators to fix the state issues we laid out at the start.

what changed?

  • there used to be no 'real' client side state, everything had to be encoded in the url / cookie since the client side was overwritten with every request
  • whatever you kept in arrays or classes in javascript was gone when the next request was made, the javascript got completely reloaded
  • with an SPA the client becomes persistent too; we adapt an existing page with json coming from the server, but the javascript is not reloaded
  • we can now hold a cache of the server state, and some client specific state on top of that

5 kinds of state

state is stored, and duplicated, at various places in our app

  • server state
  • persistent client state
  • local client state
  • url (or router) state
  • transient client state

server state

  • typically some sort of database which stores info about every user and every aspect of our webapp
  • providing efficient and correct access to this state is a (really) hard problem in itself (sharding, CDN, ...) but not the topic of today

persistent client state

  • this is a subset of the server state on our client, often limited to the current user and what she has done and seen till now
  • while 'in the end' you want the server and persistent client state to be the same, at certain points in time they can (and will) differ
    • server can be ahead: the client still needs a 'refresh'
    • the client can also be ahead, optimisticly showing updates which still need to happen on the server

local client state

  • client state that is NOT stored on the server
  • things like a local filter, sort settings on a table, ...
  • usually (but not limited to) stuff that changes the way you view the data from the server

url (router) state

  • reflects both the persistent and the local client state
  • very important that it always reflects what the user sees, people expect bookmarking, back/forward and copying links to work
  • always keep in mind users can (and will) alter the url to navigate your app, this should always work correctly

transient client state

  • less important, but some things that are part of your state will not be reflected in the url or on the server
  • the canonical example: if you pause a youtube video, switch to another window and switch back, you can continue playing where you left of, but this is nowhere reflected neither on the server nor in the url

syncing state

state synchronization

problems with state

  • in a single user, synchronous world, where everything a computer does happens instantenously, there wouldn't be problems with keeping state
  • but keeping state means syncing state, and syncing state flawlessly in an asynchronous world is hard
    • multiple sync requests can happen simultaneously, overwriting (and hence negating) eachother
    • delayed requests can result in out of order syncing, resulting in inconsistent state

URL vs local client state

  • as an example, let's adapt our recipe list component so that the url reflects what's typed in the filter input field, and vice versa,
  • requesting everything and applying client side filtering was never a good idea, so we'll pass the filtering parameters to the backend and get filtered results
  • and we'll cache these server results in our recipe list component; obviously we need state in order to have problems with state!

recipe data service

 src/app/recipe/recipe-data.service.ts 
                    
            getRecipes$(name?: string, chef?: string, ingredient?: string) {
              let params = new HttpParams();
              params = name ? params.append('name', name) : params;
              params = chef ? params.append('chef', chef) : params;
              params = ingredient ? params.append('ingredientName', ingredient) : params;
              return this.http.get(`${environment.apiUrl}/recipes/`, { params }).pipe(
                catchError(this.handleError),
                map((list: any[]): Recipe[] => list.map(Recipe.fromJSON))
              );
            } 
             
we'll start with adding a call to the API the API/recipes call has three optional parameters, to filter on recipe name, chef or ingredients (see swagger for details) so we'll add a getRecipes$ method with the three optional parameters we'll pass URL parameters using the HttpParams helper class from Angular each { key: value } pair is transformed in a ?key=value url param

recipe list component

 src/app/recipe/recipe-list/recipe-list.component.ts 
                export class RecipeListComponent implements OnInit {
                  public filterRecipeName: string = '';
                  public recipes: Recipe[];
                  
                  constructor(
                    private _recipeDataService: RecipeDataService,
                    private _router: Router,
                    private _route: ActivatedRoute
                  ... ) {}
               
                  ngOnInit() {
                    this.filterRecipe$
                      .pipe(
                        distinctUntilChanged(),
                        debounceTime(250)
                      ...)
                      .subscribe(val => {
                        const params = val ? { queryParams: { filter: val } } : undefined;
                        this._router.navigate(['/recipe/list'], params);
                      ...});
                
                    this._route.queryParams.subscribe(params => {
                      this._recipeDataService
                        .getRecipes$(params['filter'])
                        .pipe(
                          catchError((err) => {
                            this.errorMessage = err;
                            return EMPTY;
                          })
                        )
                        .subscribe(val => (this.recipes = val));
                      if (params['filter']) {
                        this.filterRecipeName = params['filter'];
                      }
                    });
                  }
                }
                 
            
next we'll adapt our recipe list component we'll update the input field based on the url, so we start with an empty string (and not undefined ) we'll also keep a local copy of the result of a query so we no longer async pipe subscribe to a result, but actively subscribe and copy the results in order to cope with the query parameters in the url, and to be able to change the url if the filter changes, we need access to both the ActivatedRoute and the Router we still subscribe to the filterRecipe$ , which emits a value every time a keyup happens in our filter input filed but instead of filtering the recipes based on this value, or requesting a filtered list from the data service, we update the url to reflect this filter the url becomes the 'source of truth', based on what the url contains we show a filtered list, when the filter changes we update the url and have the router show this new url updating based on the url is done by subscribing to the queryParams of the route we call the new getRecipe$(name, chef, ingredient) function on our data service, which gets us the filtered results and we set the contents of the input field based on the query parameters (to be consistent when someone copy pastes or changes the url) note that this doesn't result in an endless loop, we listen to keyup events to trigger routing to a new url, not to changes of the value of the input field

recipe list component

 src/app/recipe/recipe-list/recipe-list.component.html 
              <mat-card>
                <mat-form-field>
                  <input
                    matInput
                    (keyup)="filterRecipe$.next($event.target.value)"
                    placeholder="filter"
                    type="text"
                    data-cy="filterInput"
                    [value]="filterRecipeName"
                  />
                </mat-form-field>
              </mat-card>
              <div *ngIf="recipes$ | async as recipes; else loadingOrError">
                <div>
                  <div
                    class="recipe"
                    *ngFor="let recipe of recipes | recipeFilter: filterRecipeName"
                  >
                    <app-recipe [recipe]="recipe" data-cy="recipeCard"></app-recipe>
                  </div>
                </div>
              </div>
          e13393e 
lastly we'll adapt the html (flex layout stuff omitted for clarity) we'll set the value of the input field based on the url parameter (our source of truth) so the filterRecipeName variable is set when the url changes, and the input field's value changes based on that variable we no longer async subscribe to a stream, we hold a cache of the filtered list ourselves, so simply loop over that one we no longer use our (client side) filter pipe, filtering happens on the backend, so remove that as well (you could completely remove the recipeFilter class as it's no longer used) if we try this out everything appears to work correctly but...

URL vs local client state

URL vs local client state - test

  • it used to be very cumbersome to see these errors in action, but not anymore, cypress makes testing this (and seeing it fail) rather easy
  • before we can create the test however, we need to make sure tests happen as a logged in user
  • since we redirect to the login page when people or not logged in, i.e. we need to have a valid token in the localStorage

URL vs local client state - test

cypress/support/commands.js  
              Cypress.Commands.add('login', () => {
                const email = 'recipemaster@hogent.be';
              
                cy.request({
                  method: 'POST',
                  url: '/api/account',
                  body: { email, password: 'P@ssword1111' },
                }).then((res) => localStorage.setItem('currentUser', res.body));
              });
             
we handle this by adding a command to Cypress.Commands this will allow us to call this command from every test file so we create a login command, which will simply do a POST request to the login API and store the result in localStorage, with the same key our application uses

URL vs local client state - test

cypress/integration/recipelist.spec.js 
                describe('Recipe List tests', function () {
                  beforeEach(function () {
                    cy.login();
                  });
              
                  it('delayed response brings state out of sync', () => {
                    cy.server();
                    cy.route({
                      method: 'GET',
                      url: '/api/recipes',
                      status: 200,
                      response: 'fixture:recipes.json'
                    });
                    cy.route({
                      delay: 2000,
                      method: 'GET',
                      url: '/api/recipes/?name=sp',
                      status: 200,
                      response: 'fixture:spaghetti.json'
                    }).as('getSPrecipes');
                    cy.route({
                      method: 'GET',
                      url: '/api/recipes/?name=la',
                      status: 200,
                      response: 'fixture:lasagna.json'
                    }).as('getLArecipes');
                    // ... all the stub routes

                    cy.visit('/');
                    cy.get('[data-cy=filterInput]').type('sp');
                    cy.wait(300);
                    cy.get('[data-cy=filterInput]').type('{backspace}{backspace}la');
                    cy.wait(['@getSPrecipes', '@getLArecipes']);
                    cy.get('[data-cy=recipeCard]').should('have.length', 1);
                    cy.get('[data-cy=recipe-title]').should('contain', 'Lasagna');
                  });
                });
          eba887f
our tests start with a beforeEach where we call this login command ensuring we're always loggin in when we perform a request the test itself starts with stubbing some requests GET of all recipes returns the fixture we defined earlier, containing a recipe for spaghetti, lasagne and risotto the GET of the same url, but with a queryparameter containing 'sp', returns a new fixture containing only the spaghetti recipe finally, the GET with the 'la' query parameter returns the lasagna recipe (also a newly created fixture, with one recipe in it) notice how we added a delay to the 'sp' request, but not the others this is what makes it possible to test this scenario also note how we gave the requests a name, this makes it easier to wait for them to complete (rather than wait an arbitrary long enough time) now all that's left is performing two searches nothing new here, select the input field and type 'sp', followed with typing 'la' we wait 300ms between typing, because our filter has a debounceTime(250) , otherwise this would be seen as one search wait till all requests are done and now check that the 'lasagna' recipe is shown this last test should fail with our current code let's try this out

core of the problem

  • our real problem is twofold
    • there is no clear sync strategy
    • side effects are not separated
  • one of the more popular ways of dealing with this state problem is facebook's flux architecture, implemented for React as Redux, or for Angular as NgRx
  • (others are popping up lately, one of the more interesting ones being MobX)
  • we'll choose another route though, we'll go pure RxJS, we'll solve state syncing problems by not holding state ourselves

RxJS Flattening

  
                const greetPeople$ = of('Destiny', 'Melody', 'Candy');
                
                greetPeople$
                  .pipe(map(name => `hi ${name}, nice to meet you!`))
                  .subscribe(result => console.log(`${result}`));

                const http = {
                  talkToMe$(name) {
                    return of(`Hi ${name}, nice to meet you!`,
                              `Is ${name} your real name or your stripper name?`);
                  }
                };

                greetPeople$
                  .pipe(
                    mapmergeMap(name => http.talkToMe$(name)),
                    mergeAll()
                  )
                  .subscribe(result => console.log(`${result}`)resultObservable => resultObservable.subscribe(result => console.log(`${result}`)));
                
                // [Object object]
                // [Object object]
                // [Object object]

                // Hi Destiny, nice to meet you!
                // Is Destiny your real name or your stripper name?
                // Hi Melody, nice to meet you!
                // Is Melody your real name or your stripper name?
                // Hi Candy, nice to meet you!
                // Is Candy your real name or your stripper name?
                
                http.talkToMe$('Shaniah').subscribe(console.log);
              
                // Hi Shaniah, nice to meet you!
                // Is Shaniah your real name or your stripper name?
                // hi Destiny, nice to meet you!
                // hi Melody, nice to meet you!
                // hi Candy, nice to meet you!
            
before we can fix our state problems, we'll first introduce a couple RxJS operators that will allow us to handle these situations let's start with a simple example we have an observable which emits strings ( greetPeople$ ), but we want to convert these string using a map function nothing special, the converted string is logged but what if the conversion function we want to use returns an observable itself? if you pass a function that converts A into B in the map, the Observable<A> will be converted into an Observable<B> say we have a talkToMe$ stream, applying two greetings to a name returning the result as an observable (e.g. because it comes from some http service) applying this to a string does as you'd expect, logging both greetings but what if you combine them? every time our greetPeople$ stream emits a new name, we pipe it through our observable-greeting generating function the result? ... the problem is that after the subscribe, our result is still an Observable<string> because the result of our pipe function was an Observable< Observable<string> > an obvious way of dealing with this is subscribing on the result inside the first subscribe which gives you the result you'd want since this is a common operation, and more involved then we show here (subscription lifetimes matter) there is an rxjs/operator which achieves exactly this as the last step of your pipe, where you have the Observable < Observable<> > , you pipe through mergeAll and inside your subscribe you get a string again map'ing and merge'ing afterwards is so common there is shorthand for this, to do this at once: mergeMap

rxjs flattening

  • flattening means mapping to an observable and immediately subscribing to the result and returning the result of the subscribe
    (while also managing unsubscribes, not leaking memory)
  • in the example of the previous slide the observables have all values available at the start and complete immediately, 'real' observables often have delays between emitting values, and then a new problem pops up
  • when a second inner observable wants to start a subscription while the first is still not completed, what should happen?
  • there is no right or wrong answer, it depends on what you want to achieve; this is called the 'merge strategy'

rxjs flattening

  • you would usually use the combined operators mergeMap / switchMap / ... i.s.o. map first, followed by mergeAll / switchAll / ... I didn't because it's easier to explain in two separate steps
  • There is no 'best' flattening operator, they all have their uses and it depends on your use case, let's quickly give some examples where you'd use each one
  • (the example from the previous slides is implemented in rxjs_flattening.js in the observablesExamples repo)

merge

  • merge means subscribing to all streams and passing everything along as it comes in
  • a usage example would be showing data where your info comes from multiple (third party) sources, e.g. you show movie info and request some extra info from imdb and rotten tomatoes. As soon as any of those http requests finishes, you update the existing info on the screen.

switch

  • switch means only subscribing to the very latest stream, and aborting all the others
  • a typical use case is searching, if the user starts typing a new search string, we no longer care about any previous search results (this solves our recipe filter problem!)

concat

  • concat means queueing any new streams and only subscribing if the previous one finished
  • a use case is not hammering some third party service with hundreds requests at once because you try to display hundred items

exhaust

  • exhaust means ignoring any new streams as long as the ongoing one isn't finished
  • a typical use case is e.g. a login screen, if the user types his username and password but has the patience of a three year old and keeps clicking login-login-login while the first login request is still ongoing, you don't want to send dozens of login requests to your server

url vs local state - fixed

 src/app/recipe/recipe-list/recipe-list.component.ts 
              this._route.queryParams.subscribe.pipe(
                switchMap  map(params => {
                    if (params['filter']) {
                      this.filterRecipeName = params['filter'];
                    }
                    return this._recipeDataService.getRecipes$(params['filter']);
                  })
                )
                .pipe(
                  catchError((err) => {
                    this.errorMessage = err;
                    return EMPTY;
                  })
                )
                .subscribe(val => {
                  this.recipes = val;
                });
                if (params['filter']) {
                  this.filterRecipeName = params['filter'];
                } 
              });
            4ab3cf9
our problem is that based on new url parameters (i.e. a filter) we request recipes, and show the results as they come in if we make two consecutive calls to the getRecipes$ and their results arrive out of order, we end up in an inconsistent state when a new filter comes in, we basically want to completely ignore the previous one we saw how to do this, using a switchMap i.s.o. immediately subscribing to new parameters, we will map each parameter to a list of recipes so remove the subscribe and replace it with piping the result through a map function we're in Observable of Observable territory here, so we need to flatten this we'll use switchMap to only process the last filter because of our restructuring, we need to move the setting-the-filter code, params is only available here that's it, lets try this out and see that our cypress tests no longer fail

url vs local state - fixed

 src/app/recipe/recipe-list.component.ts 
              constructor( ... ) {
              this._fetchRecipes$ = this._route.queryParams
                .pipe(
                  switchMap((newParams) => {
                    if (newParams['filter']) {
                      this.filterRecipeName = newParams['filter'];
                    }              
                    return this._recipeDataService.getRecipes$(newParams['filter']);
                  })
                )
                .pipe(
                  catchError((err) => {
                    this.errorMessage = err;
                    return EMPTY;
                  })
                );
                .subscribe((val) => {
                  this.recipes = val;
                }); 
            }

            get recipes$(): Observable<Recipe[]> {
              return this._fetchRecipes$;
            }
           
we can go further, why bother caching the recipes here, there's really no need so we'll re-introduce our fetchRecipes$ stream this means we'll have to reintroduce the recipes$ | async as getRecipes in our html as well and we'll simply assign the observable right before we subscribe, subscribing is done by the async pipe that's it

fixed?

  • while this fixes our url vs local state problem, there's still another problem: try deleting a recipe, it won't disappear until you refresh!
  • that's because our getRecipe(name...) immediately returns the result of the backend query, we no longer cache the result and update said cache when something is added / deleted
  • (that was a contrived solution anyway, updating a shadow cached version is often a bad idea regardless (if multiple people can update), but we couldn't fix it for real before you knew about switchMap)
  • but now you do!

recipedataservice -revised

 src/app/recipe/recipe-data.service.ts 
              private _reloadRecipes$ = new BehaviorSubject<boolean>(true);
              
              deleteRecipe(recipe: Recipe) {
                return this.http
                  .delete(`${environment.apiUrl}/recipes/${recipe.id}`)
                  .pipe(tap(console.log), catchError(this.handleError))
                  .subscribe(() => {
                    this._reloadRecipes$.next(true);
                  });
              }

              getRecipes$(name?: string, chef?: string, ingredient?: string) {
                return this._reloadRecipes$.pipe(
                  switchMap(() => this.fetchRecipes$(name, chef, ingredient))
                );
              }
            
              fetchRecipes$(name?: string, chef?: string, ingredient?: string) {
                let params = new HttpParams();
                params = name ? params.append('name', name) : params;
                params = chef ? params.append('chef', chef) : params;
                params = ingredient ? params.append('ingredientName', ingredient) : params;
                return this.http.get(`${environment.apiUrl}/recipes/`, { params }).pipe(
                  catchError(this.handleError),
                  map((list: any[]): Recipe[] => list.map(Recipe.fromJSON))
                );
              } 
          4095479 
we can use our flattening knowledge to make sure the query is executed again when certain actions happen (delete in our case) this way we're always sure we show the current version from our backend reason with me, the getRecipes$ does a http.get call http calls complete, return, and that's it; somehow we want the getRecipes$ stream to contain the result of a new http call on command we start by splitting the function into two, our getRecipes will call fetchRecipes on command we achieve this by adding a new stream, on which we can put values ourselves and whenever a value appears, we map this value to an execution of the fetchRecipes$ call since our boolean is mapped on an Observable of Recipes the map on the reloadRecipes stream would create an Observable of Observable of Recipes, so we need to flatten we use a switchMap here, whenever we request new results, any previous requests that are finished can safely be thrown away, we don't need the results anymore the only thing we still need to do is put a new value on the reloadRecipes stream when we want the query to be executed again so e.g. when a delete happens, we put a new value on this stream, triggering the switchMap to call fetchRecipes again which will trigger a new value on whomever is subscribed to the getRecipes stream, containing all recipes as they are present in our backend at this moment that's it! there is a cypress test showcasing this added to the repo as well let's try this out

reactive programming

  • we only scratched the tip of the iceberg
    (no really, there are 134 rxjs operators right now)
  • you can do cool stuff with very little code, but you sometimes really have to apply a different way of thinking and organising your code
  • reactive programming is 'in' right now, the next few years it'll get bigger for sure
  • it's probably here to stay in some capacity, who knows, time will tell
  • at the very least you now had some introduction to the subject

that's it! We're done

win95 launch party gif

let's party like
it's the windows 95 launch!

final words

wiske einde
  • I hope you enjoyed the course, or at the very least learned something
  • special thanks to Ms Samyn for creating the backend code and slides, and both Ms Samyn and Mr De Cock for finding a lot of small and big mistakes in my slides and code
  • and thanks to all the students who made pull requests to fix mistakes in the slides over the years, if you spot(ted) any mistake, don't hesitate
  • finally, good luck with the exam!