meteor: template joins
how to perform a template-level reactive join
Managing the flow of data in a meteor app can be challenging. Subscriptions can be activated from anywhere, and there's no easy way to determine which templates depend on which subscriptions. One technique to make these dependencies explicit is to migrate higher-level subscriptions down to the template.
On the surface this seems straightforward. Simply call this.subscribe inside of an onCreated callback, and you're done.
But what happens when your subscriptions are complex? Specifically, what if you need to perform a reactive join on the client? This tricky class of subscriptions will be the focus of this article.
We'll begin with the canonical posts
-> comments
-> users
collections. Our task is to implement a singlePost
template as shown below:
<template name="singlePost">
<h1>{{post.name}}</h1>
{{#each comments}}
<div class='comment'>
<p>{{author.name}}: {{message}}</p>
</div>
{{/each}}
</template>
For brevity, we'll skip the helpers and focus on the subscription code.
Simple join
Here's an example implementation of a simple (non-reactive) join:
Template.singlePost.onCreated(function() {
var self = this;
// {{> singlePost postId=post._id}}
var postId = Template.currentData().postId;
// subscribe for this post
self.subscribe('post', postId);
// subscribe for all comments for this post
self.subscribe('comments', postId, function() {
Comments.find({postId: postId}).forEach(function(comment) {
// subscribe for the author of this comment
self.subscribe('user', comment.authorId);
});
});
});
The postId
is extracted and used to activate subscriptions for posts and comments. Inside of the callback for the comments subscription, we request the user document for each author, thereby joining the two collections.
Note that even when using callbacks,
Template.subscriptionsReady
will only be set totrue
after all of the initial record sets have arrived.
Simple join with an autorun
One common problem with our first implementation is that the template's underlying data could update after onCreated
has executed. postId
could initially be undefined
and then transition to a valid id. The solution is to move our subscriptions into an autorun which depends on Template.currentData():
Template.singlePost.onCreated(function() {
var self = this;
// this will rerun if postId changes
this.autorun(function() {
// {{> singlePost postId=post._id}}
var postId = Template.currentData().postId;
// subscribe for this post
self.subscribe('post', postId);
// subscribe for all comments for this post
self.subscribe('comments', postId, function() {
Comments.find({postId: postId}).forEach(function(comment) {
// subscribe for the author of this comment
self.subscribe('user', comment.authorId);
});
});
});
});
Now whenever postId
updates, the subscriptions will be restarted.
Reactive join
The previous implementation will work until a new comment is added. Because the autorun
doesn't reevaluate when new comments arrive, new authors will not be published.
We can solve this by making our join reactive. In this final version, we'll keep track of a unique list of author ids and use it to restart the users
subscription as needed.
Template.singlePost.onCreated(function() {
var self = this;
// store a unique list of user ids we need to subscribe for
var userIds = new ReactiveVar;
this.autorun(function() {
// {{> singlePost postId=post._id}}
var postId = Template.currentData().postId;
// subscribe for this post
self.subscribe('post', postId);
// subscribe for all comments for this post
self.subscribe('comments', postId);
// subscribe for all authors for all comments
self.subscribe('users', userIds.get());
});
this.autorun(function() {
// {{> singlePost postId=post._id}}
var postId = Template.currentData().postId;
// fetch the author ids for all comments currently on the client
var authorIds = Comments
.find({postId: postId})
.map(function(comment) {return comment.authorId;});
// update the reactive variable with a unique list of ids
// this will cause the users subscription to restart
userIds.set(_.uniq(authorIds));
});
});
The first autorun
will restart whenever postId
or userIds
is updated. Of course, meteor is clever enough to restart only those subscriptions whose dependencies have changed.
The second autorun
simply extracts the author ids into a reactive variable. It's worth mentioning that this implementation only requires a single users
subscription and only updates when a new authorId
appears on the client.
Final thoughts
Here's a quick summary of the trade-offs between server and client reactive joins:
Advantages
Server joins can be computationally expensive memory hogs. Furthermore, unless you are a glutton for punishment, they require a sophisticated community package. On the other hand, client-side joins are comparatively lightweight and can be implemented using meteor's native API.
Disadvantages
Client joins require the delivery of one set of documents before the dependent set can be requested. Because of this additional round-trip, we should expect a higher overall latency than with server joins. Client joins also imply more granular publish functions, which may be a security concern in some applications. In the example above, we assumed a users
subscription which takes any array of ids without context. Compare that to a publisher like postAndCommentsAndAuthors
, which only transmits the user documents related to a particular post.
Overall I think template-level joins are a powerful tool to help manage data flow in your app, however I don't believe they are a panacea. You'll still need to choose the right technique for your app based on the situation.