Shuffling values returned from an Observable

Viewed 324

TL;DR I have an array of names that is returned from a network request which I want to shuffle so that the names are in a different order each time the request is made. Additionally, I want to select 6 names from the collection without getting duplicates. I have a CodePen that reproduces the issue, using of instead of Angular's http.get, where I see duplicates in the subsequent selection of groups of 6: https://codepen.io/mcmillhj/pen/rNxNaEB

Problem:

I am working on a small app in Angular that lets you learn your co-worker's names. I make a network request that gets a JSON response that includes names and images for the game. In order to make the game more interesting, and not in the same order each time, I applied the lodash.shuffle operation when the observable is evaluated. I expected this to apply the shuffle operation a single time but I can see it being evaluated every time I request a grouping of 6 people. This leads to the same name appearing in multiple groups. I am sure this is down to me not yet understanding the semantics of these RxJS primitives.

Code:

const { of } = rxjs;
const { map } = rxjs.operators;
const { shuffle } = _;

const getAllPeople = () => {
  return of([
    "Dorothy Baker",
    "Simon Alsop",
    "Samantha Coleman",
    "Ava May",
    "Rachel Quinn",
    "Dominic Taylor",
    "Donna Stewart",
    "Lillian Campbell",
    "Katherine Rampling",
    "Nathan Sutherland",
    "Gabrielle Wilson",
    "Amy Hughes",
    "Jason Wright",
    "Matt Nolan",
    "Natalie Hughes",
    "Amanda Roberts",
    "Megan Parr",
    "Una Bell",
    "Anthony May",
    "Sean Slater",
    "Adrian Powell",
    "Kimberly Walker",
    "Sebastian Black",
    "William James"
  ]).pipe(
    map((people) => shuffle(people)),
  );
};

const people = getAllPeople();
let cursor = 0;
const getNPeople = (n) => {
  const peoplePage = people.pipe(
    map((data) => {
      const page = data.slice(cursor, n + cursor);
      cursor += n;

      return page;
    })
  );

  return peoplePage;
};

for (let page = 0; page < 4; page++) {
  const ul = document.createElement("ul");
  document.body.appendChild(ul);
  getNPeople(6).subscribe((people) => {
    for (const person of people) {
      const li = document.createElement("li");
      li.innerText = person;
      ul.appendChild(li);
    }
  });
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.5.5/rxjs.umd.min.js"></script>

3 Answers

Let's see why it shuffles every time you request a group of people by breaking the problem into a simpler one:

// `src$` = getAllPeople();
const src$ = new Observable(subscriber => subscriber.next(Math.random()));


// Group 1
src$.subscribe(console.log)

// Group 2
src$.subscribe(console.log)

// Group 3
src$.subscribe(console.log)

As expected, you should see three different numbers. This is called a unicast observable, meaning that each consumer will get its own value, meaning that the function which represents the source(i.e the Observable's callback) will be called each time a subscriber registers.


A quick fix would be this:

const people = getAllPeople().pipe(publish(), refCount());

publish() + refCount() will place a Subject instance between the data producer(e.g of([...])) and the data consumers(e.g getNPeople(n).pipe()).

This is how the program looked before:

c{n} - consumer n

    src
|    |     |
|    |     |
|    |     |
c1   c2    c3

so, for each consumer(subscriber), the source will call the function that produces the data, meaning that each consumer will get its own different result.

With a Subject in front of data producer, this is how the program looks now:

c{n} - consumer n

        src
         |
         |
         |
      SUBJECT
    |    |     |
    |    |     |
    |    |     |
    c1   c2    c3

The source will be invoked once, in this case, as soon as there is at least on subscriber(due to refCount) and when a new subscriber is created(e.g another getNPeople(n)), the subscriber will be added to the Subject's list of subscribers.

When the source finally emits, the Subject will send the value to all its registered subscribers. This is called multicasting.


One important aspect of this solution is that it assumes that the source is asynchronous and the subscribers are all registered synchronously.

The second condition seems to be fulfilled(as the subscriptions are created in a loop), but the first one not. One quick way to fix this would be to use subscribeOn or delay(0)

const getAllPeople = () => {
  return of([...])
    .pipe(
      /* ... */
      subscribeOn(asyncScheduler) // delay(0)
    )
}

subscribeOn(asyncScheduler) will make sure that the source is subscribed in the next tick, allowing the subscribers to be registered synchronously.

The function getNPeople(6) is called inside the loop. Each call to getNPeople creates new independent observable. Each observable does an independent call to the web API.

Here a solution where you subscribe once:

getAllPeople().subscribe((people) => {
  for (let page = 0; page < 4; page++) {
    const ul = document.createElement("ul");
    document.body.appendChild(ul);

    for (const person of people) {
      const li = document.createElement("li");
      li.innerText = person;
      ul.appendChild(li);
    }
  }
}

You need to segment into two sections:

  1. Get your data, shuffle and then retain the result array in a variable. You don't need to retain the observables.

  2. Return the group of values from the shuffled result.

Something like this:

people : string[];
ngOnInit() {
  this.getAllPeople().subscribe((shuffledPeople) => {
    this.people = shuffledPeople;
  });
}

const getNPeople = (n) => {
  const limit = ((n + cursor) > this.people.length) ? this.people.length : n + cursor;
  const page = this.people.slice(cursor, limit);
  cursor = limit;

  return page;
};

EDIT: your could try to do something like this for your service


people: string[] = null;

// call this function when you want to reset the people value and let the
// getAllPeople function execute
resetPeople(): void {
  this.people = null;
}

// return the people already shuffled, or request the new people and then shuffle
loadPeople(): Observable<any> {
  if (this.people !== null) {
    return of(this.people);
  } else {
    return this.getAllPeople().pipe(
      tap((shuffledPeople) => this.people = shuffledPeople),
    );
  }
}

getNPeople(n: number): string[] {
  this.loadPeople().subscribe(shuffledPeople => {
    const limit = ((n + cursor) > shuffledPeople.length) ? shuffledPeople.length : n + cursor;
    const page = shuffledPeople.slice(cursor, limit);
    cursor = limit;

    return page;
  });
};

To accomplish i used tap and of from rxjs

Related