I'm losing reactivity on the client


#1

Here’s the thing. I know that in order to get reactivity on the server side publications working properly I need to keep my queries simple and avoid joins.

What I supposed though is that I could do something more similar to a join on the client, while keeping all the reactivity advantages. I’m trying to solve this avoiding to denormalize some more data on the Invitations collection. I read what I could find on the Internet (like https://www.discovermeteor.com/blog/reactive-joins-in-meteor/) but they all discuss server side reactivity.

This is my case. I have an Invitations collection and a Channels collection. People receive invitations to join channels and they need to accept before joining. Channels can be archived, so when you archive a channel, you don’t want to show any open Invitation related to that channel anymore. To get my Invitations on the client template I created a helper like this:

//client library function
libGetListedChannels = function() {
  return Channels.find({status: libChannelStatus.listed}).fetch().map(function(c){return c._id;});
}
...
 //template helper
  pendingInvitations: function() {
    return Invitations.find({accepted : libInvitationStatus.pending,
                            guestId : Meteor.userId(),
                            channelId : {$in : libGetListedChannels()}});

Am I doing anything wrong or this is just a limit of reactivity on the client?


#2

I think libGetListedChannels need to be reactive var, not function.


#3

Never used reactive vars even if I read about them. That problem with ReactiveVar is that I need to invalidate that variable when listed channels query – not archived – changes. That means loosing Meteor reactivity.
Am I wrong?

Can you share a sample for my case?


#4

You can make some optimizations. First, always project out fields from a query which you aren’t going to use. In your library function, you’re asking for everything, iterating over it twice, and then forgetting about 99% of it. You shouldn’t use the pattern cursor.fetch().map(). Basically, it forces you to iterate the collection 1 extra time.

If you are going to use this approach, do this: cursor.find(query, {fields:{_id:1}}).map(function(doc){return doc._id})

But if you’re going to be doing this a lot, then the better solution is to use an observer on the client and cache the array in a ReactiveDict (e.g. the Session).

Then, you can use {$in: Session.get('channelArray')} in your template helper and it will be reactive.

In general, if you can sacrifice CPU on the client, you can use an observer to simulate reactive joins on the client easily depending on the level of reactivity you need. If you only need a few fields to be reactive, then your join logic will be fairly simple. You can spew data into null collections from several collections, or you can split data into different null collections, etc.

In your case, in the added callback of the observer

auxArray = [];
var init = true;
cursor.observeChanges({
 added: function(id){
  if (init)
   auxArray.push(id)
  else
   //set the channelArray in the Session to itself.concat(id)
 },
 removed: function(id){
  //set the channelArray in the Session to _.without(itself, id);
 }
})
init = false;
Session.set('channelArray',auxArray)
delete auxArray

#5

Often, using an unmanaged local collection as the local cache results in cleaner code than any other data structure.


#6

In this specific case, the developer needs an array for the mongo selector. The best solution depends on how often the data changes and how often the data needs to be accessed. If the data doesn’t change much and it’s accessed a lot, then it would be preferable to store it as an array in a reactive dict or something, because you’d save an array iteration for every access compared to the other solution.

If the data does change a lot and is accessed seldom, then It would be even cleaner to perform a projected query (followed by a map) straight out of the parent collection. I wouldn’t use a null collection for storing projected data, though - in this specific case, it’s an unnecessary step.


#7

Thanks @streemo and @Steve.
I adopted the Session-based solution, but it doesn’t work as expected. The helper function does not seem to be reactive. I’m sure it’s my fault, but I cannot see where I did wrong.
Now it works like that:

I initialize the variables in the onCreate of my layout template (the outer template). I suppose this is the best place to put it because I’m using template based subscriptions:

Template.layout.onCreated(function(){
	var self = this;
	self.autorun(function(){
		...
		self.subscribe('channels');
		if (Template.instance().subscriptionsReady())
			Session.set('listed-channels', libGetListedChannels());
	});
...

The library function is the same as before, but thanks to @streemo suggestions now performs better - thanks, I love to learn something new. So:

 libGetListedChannels = function() {
  return Channels.find({status: libChannelStatus.listed}, {fields : {_id :1}}).map(function(doc){return doc._id;});
}

the invitationList template looks like this:

Template.invitationList.helpers({
  pendingInvitations: function() {  
    return Invitations.find({accepted : libInvitationStatus.pending,
                            guestId : Meteor.userId(),
                            channelId : {$in : Session.get('listed-channels')}});
...

and I added some code in the 2 template events when I submit an add or archive action on a channel (where t is the template argument to the event function):

...
t.instance().autorun(function(){
    if (t.instance().subscriptionsReady())
      Session.set('listed-channels', libGetListedChannels());
  }) 

Now, in my understanding the pendingInvitations template function should be reactive now, but it isn’t.


#8

There are a few problems. You’re taking a lot of orthogonal steps, each of which could independently suffice as the entire solution.

In event handlers, the second argument is the template instance object. You will get an error saying that t.instance() is not a function.

Second, I would stay away from using reactivity in event handlers. It is an anti-pattern. You don’t want to end up with an iron-router-like situation where the same stuff is happening N times more than you expected. Depending on how many times the user clicks the button, you will run that many re-computation functions, each of which will run map on the collection. Not good! Most of the time, a user action should not be reactive. Data coming from the server should.

You misinterpreted my use of the Session variable! The point of using Session was to avoid performing map operations every time the template helper was re-run. Currently, the session is an unnecessary step in your process. You can use it with an startup observer instead of map.

The cursor is reactive, you do not need to use the event handler to perform a reactive re-set of the Session - this will happen automatically in the helper. The helper will use the most up-to-date array if you are using map, because you’re querying the collection directly. Keeping track of changes is only necessary if you want to pre-package everything into Session since there is no default link for the Session to get updated; you need to write that code yourself.

See my previous post - I wrote a working example of this code above. It uses an observer in /client/lib/startup.js to make sure the Session reflects the current state of the Id’s array.

Another solution

If you want to minimize client-side computing even more, then you can keep track of the current Ids in the server using a connection based Cache. In the publication, you can instantiate an array of Ids in initialization of the observer, and then call self.added(‘metadataClientSideCollection’, id, {channelArray: array}) after initialization. Then, after initialization, on removed, you will do array = _.without(array, id) and self.changed(‘metadataClientSideCollection’,id,{channelArray:array}), and on added, you will do a similar thing but with concat, since you’re adding a new id.

This means that the server will push the up-to-date array to the client and it will be ready for use immediately. This will mean higher bandwidth, less computing on the client, and more computing on the server.

If you aren’t clear what the above server solution means, get familiar with the added/changed/removed publication API - it is very useful. You will also need to be familiar with how to ignore all of the initially added documents to an observer. It is very simple and can be learned here: var initializing = true, …


#9

Can’t help more specifically because it’d take way too much time to understand what’s possibly going on exactly, but:
Pretty sure you’re tripping over your own feet here, because of the complexity of what’s going on. Meteor’s reactivity works, and it’s not immediately clear how you’re “losing it”. It shouldn’t be happening, you’ve got the basic parts right.
I’d suggest to create a minimal app with just the 2 basic collections with only one or so property to join them on and only put minimal items into both collections, and create a minimal UI that shows what you’re trying to accomplish here.
You’ll likely find that it’s easy to get it to work or it’ll be easy to really understand what’s going on. And from there you can introduce more complexity, like with all your query selectors and potential conditions of when all those queries might not actually return / fetch anything etc.
When in doubt: simplify. Until you understand fully what’s going on. From there increase complexity until you either don’t understand any more or something undesired happens. Investigate. Rinse and repeat.


#10

Hi @streemo you are absolutely right, t.instance() was a typo. Thanks.

About the rest I need to understand better how to use observers within publications to get to the most elegant solution to my problem. I used observer on the client, but never on the server. I guess it’s pretty similar.

My approach can’t work: I misunderstood the scope of Session variables. Channels are shared between users and when a user archive a channel, he changes its status from listed to archived. Of course that change should be reflected on the invitations that other users received before that channel was archived.
In any case, I can’t use a Session variable to share anything among users - like listed channels. Session is variable with user scope and not application scope.

Thinking about this problem I thought that the easiest way to solve it is adopting a completely different pattern. Every time I archive a channel (delisting it), I should archive all pending invitations to that channel too. That means adding a status field to the invitation record or simply remove that invitation from the database. MongoDB doesn’t manage atomic transaction between multiple collection updates and that is an hassle. I need to solve it double checking data integrity between these 2 collections (channels and invitations) manually in the program - if one of the 2 fails, then rollback the other. I don’t know any other way.

I’m intrigued by your “Another solution” paragraph, but I need more time to understand how that could be done using what you suggested:


#11

@seeekr I follow your suggestion too. Reading Meteor documentation and Discover Meteor chapters once again, I found I was misinterpreting Session variables scope.


#12

Like seeekr, I can’t say I understand for sure exactly what you’re trying to accomplish, you seem to have a very specific use-case. But I think the general principles will help you realize that there is a simple solution.

I’ll try my best to understand. To me, it seems like you have channel objects which can be deactivated by any user, and that deactivation should be reflected to all other others. If you want every user to have immediate access to which channels are active, you can publish a boolean map to every user upon connecting. The logic is as follows:

On connect, subscribe to ‘active-channels’. In the publish function, use an observer to listen to channels with {fields:{active:1}}. On added, push the single boolean field to a client-side collection via self.added, preferably not to the Channel collection. On removed, use self.removed to remove the document. Or, you can accomplish the same thing using the succinct cursor notation instead of added/changed/removed. I prefer the latter because it allows more control if it needs to be added in the future.

Then, on every single client, you can run ActiveChannels.find().map(function©{return c._id}) to get that array you desired. This will be reactive, of course. When ANY user deactivates a channel, the array will re-construct itself on all clients. You wouldn’t need to complicate matters with reactive event handlers - this simple solution gets you a reactive active-channel list - the usage of this list will depend on your specific use case.

(Note that you will have to update the {active:Boolean} field when a user performs an action via a method, or something. That’s a separate part, though).


#13

Just to clarify.
The use case is pretty standard: a chat with private group channels. People get invited to join a channel and need to accept the invitation before they can access the channel itself. A channel can be archived by its creator and when that happens pending invitations should be treated consistently – which means I cannot have a pending invitation to an archived channel.
It’s really simple.