Skip to content

Instantly share code, notes, and snippets.

@Sewdn
Last active December 8, 2016 08:56
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save Sewdn/10616795 to your computer and use it in GitHub Desktop.
Save Sewdn/10616795 to your computer and use it in GitHub Desktop.

TL;DR

A query defines a set of conditions on a collection of documents. Most of the time, only the documents that meet these conditions need to be published to the client. In many cases the query's conditions are subject to the state of the application (for instance the selected sorting field). This pattern describes how to update your query's result set reactively with meteor without losing the cursor's state. This way, results are preserved over different adjustements of a query if they meet both set of conditions.

an example case

To illustrate this pattern best, we'll be using the following example case throughout.

Let's say you have a collection of Players and a collection of Games. We track each score in a Scores collection. Some example data:

Players:

[
  {_id: "001", name: "Frans"},
  {_id: "002", name: "Jos"},
  {_id: "003", name: "Alain"}
]

Games:

[
  {_id: "001", name: "snake"},
  {_id: "002", name: "tetris"}
]

Scores:

[
  {_id: "001", player: "001", game: "001", score: 350},
  {_id: "002", player: "002", game: "001", score: 400},
  {_id: "003", player: "003", game: "001", score: 450},
  {_id: "004", player: "001", game: "001", score: 420},
  {_id: "005", player: "001", game: "002", score: 3500},
  {_id: "006", player: "003", game: "002", score: 2500},
  {_id: "007", player: "003", game: "002", score: 3700}
]

We want to list the highscore ranking for a game. When another game is selected, the ranking must be updated accordingly.

the evident approach

To template the ranking, we iterate over the result of a query that is run inside a ranking helper, and build an ordered list in the DOM.

ranking.html:

<template name="ranking">
  <ol>
  {{#each ranking}}
    {{#with dataScope}}
    <li>{{player.name}}: {{score}}</li>
    {{/with}}
  {{/each}}
  </ol>
</template>

The query returns all scores for a game who's ID is stored in a session variable selectedGame. To link the related player's data to the score document, we change the scope with a custom dataScope helper to fetch the related data.

ranking.js:

Template.ranking.helpers({
  'ranking': function(){
    return Scores.find(
      {game: Session.get('selectedGame')},
      {sort: {score: -1}}
    );
  },
  'dataScope': function(){
    return {
      player: Players.findOne(this.player),
      score: this.score
    }
  };
});

In order for this to work, we also need our data to be published depending on the currently selected game.

client.js

Meteor.startup(function(){
  Deps.autorun(function () {
    Meteor.subscribe('gameRanking', Session.get('selectedGame'));
  });
});

As soon as the selectedGame Session variable changes, the subscription is rerun, new data is being published and the query is rerun, matching the newly published documents.

Not only the Scores need to be published, but also the selected game in the Games collection and the player data of the player that registered the score to the Players collection.

server.js

Meteor.publish('gameRanking', function(gameId){
  check(gameId, String);
  // lookup all playerIds that have played this game
  // and make them unique
  var playerIds = _.uniq(_.pluck(
    Scores.find({game: gameId}, {fields:{player: true}}).fetch(),
    'player'
  ));
  return [
    Scores.find({game: gameId}),
    Players.find({_id: {$in: playerIds}}),
    Games.find({_id: gameId})
  ];
});

the drawbacks

This approach works. But there are a few drawbacks.

First of all, this is not very DRY. We have to duplicate the conditions of our query in the client side data-query and the server-side data publication. We need this query on the client-side, because we never know that no other subscription is subscribing to other scores in our collection. So we have to filter them again on the client.

Secondly, when the reactive context changes (the selected game), lots of things are going on:

  • the query is rerun: a new cursor is constructed for the new gameId, resulting in an empty cursor (because no scores for this game are published yet). Therefor, all DOM in the ranking template will be removed reactively.
  • the subscription is rerun. The previous subscription was canceled and a new one was started.
  • new data is being published by the newly started subscription to the Scores collection
  • all the scores matching our query are being added to the live cursor, and are drawn to screen reactively.

The biggest problem is that our client-side query on our Scores collection is rerun as soon as one of the criteria of our filters change. This makes it impossible to transition documents that match both states of the query. They are redrawn anyway, because the cursor was reset.

a better aproach

Instead of publishing the related scores to the existing Scores collection, we are going to publish to a dedicated ranking collection that only exists on the client: gameranking. This is our "Query Collection". We only publish data to this collection that meets our conditions.

client.js

GameRanking = new Meteor.Collection("gameranking");
Meteor.startup(function(){
  Deps.autorun(function () {
    Meteor.subscribe("gameRanking", Session.get('selectedGame'));
  });
});

When a new subscription starts for this publication, all scores for the provided gameId must be subscribed to, and with them all related data to the other collections (Scores, Players) (if needed). Note the id we use to publish to the gameranking collection: it's the same id as the score document from the Scores collection.

server.js

Meteor.publish("gameRanking", function (gameId) {
  var self = this;
  check(gameId, String);
  var handle = Scores.find({game: gameId}).observeChanges({
      added: function (id, data, index) {
        self.added("gameranking", id, {
          score: data.score,
          player: data.player
        });
        self.added("Scores", id, data);
        self.added("Players", data.player, Players.findOne(data.player));
      }
  });
  self.ready();
  self.onStop(function () {
    handle.stop();
  });
});

We now don't have to query for the right documents on the client anymore, since we know that only the relevant documents exist inside this collection.

ranking.js:

Template.ranking.helpers({
  'ranking': function(){
    return GameRanking.find(
      {},
      {sort: {score: -1}}
    );
  },
  'dataScope': function(){
    return {
      player: Players.findOne(this.player),
      score: this.score
    };
  }
});

why is this better?

This approach is DRY. We only declare our conditions once (in the data publication). We know that all of the data in our client-side query-collection meets the conditions, and just list all of them. We only need to order the list.

When the reactive context changes, only the publication is rerun. Meteor's diffing algorithm will make sure that all content published by the previous subscription that was stopped, will be removed and all new content will be added from and to the gameranking collection.

Our query is not rerun, but the cursor is made aware of new content being added and existing content being removed or reordered. Only those DOM changes are done to transition from one state to the next, instead of doing a complete redraw.

more complex publications

Sadly (in this example), still all of our DOM elements are removed and drawn back on after the subscription. There is no overlap between 2 states of our query. Let's change the requirements of our example a bit: we only want to rank the top score of each player for a game. In this case every player can be listed only once per highscore ranking, and thus can remain state over 2 highscore rankings. If a player has a highscore for 'snake' and he has a highscore for 'tetris', the li-element must be retained in the DOM and only his score needs to be altered (and he might be reordered).

server.js

Meteor.publish("gameRanking", function (gameId) {
  var self = this;
  check(gameId, String);
  var topScores = {};
  var handle = Scores.find({game: gameId}).observeChanges({
      added: function (id, data, index) {
        if(!topScores[data.player]){
          self.added("gameranking", data.player, {
            score: data.score,
            player: {
              _id: data.player,
              name: Players.findOne(data.player).name
            }
          });
          topScores[data.player] = data.score;
        } else if(data.score > topScores[data.player]){
          self.changed("gameranking", data.player, {
            score: data.score
          });
          topScores[data.player] = data.score;
        }
      }
  });
  self.ready();
  self.onStop(function () {
    handle.stop();
  });
});

We now have published only the best scores of each player for a game and we have published them to the client-side gameranking collection with the player's unique id, since we know that only one score per player will be present.

When the first score for a player was found, a new document is being added to the gameranking collection. We also add the player's name, so we don't need to publish to the player's collection anymore. Also we don't need to change the scope to fetch other data.

ranking.html:

<template name="ranking">
  <ul>
  {{#each ranking}}
    <li>{{player.name}}: {{score}}</li>
  {{/each}}
  </ul>
</template>

When a new topscore was found for a player (during the first run when fetching all of the player's score for the game, or during a later update when a new score was posted), the existing record for the player is being updated. Only the new score is being published and client side this change effect will result in an update of the score property of the document and a new sorting in the DOM.

When the reactive context changes (selectedGame Session variable), only the score property will be updated to the player's topscore for that game, leaving the document with the player's id present in the store and thus in the DOM. This way you can transition your DOM-element to it's new state.

conclusion

This pattern uses a dedicated client-side collection to publish all of the data that meets the conditions of a query. Client side you can just fetch all of the documents of this query, knowing the data-set will be updated automagically as soon as your query changes.

This approach is one of the use cases of the excellent publication/subscription construct in meteor.

There are really no limits on the complexity of the logic you want to use when publishing data. You could combine all sorts of queries on different collections and publish to a unified client-side collection referencing to all of the related documents.

@rhythmus
Copy link

Nice!

(Too bad Github Gists don’t accept pull requests; fixed some typo: http://gist.io/11123057)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment