Usage of onReady/onStop callbacks with useEffect/useTracker

I am trying to understand how to correctly use the callback version of Meteor.subscribe with React hooks. Below are two implementations of similar functionality. The first one follows the standard approach and the second one tries to get more fine-grained updates from Meteor through callbacks for the Meteor.subscribe and Collection.find methods. The first one works fine. The second one has some hiccups, for instance when loading a page for the first time results in an almost immediate onStop() while navigating the app with react-router works as expected. What am I doing wrong?

The first one

import {Meteor} from 'meteor/meteor';
import {useTracker} from 'meteor/react-meteor-data';

const makeFindOne = (Collection, subscription) => (
	init,
	query,
	options,
	deps
) => {
	const loading = useTracker(() => {
		const handle = Meteor.subscribe(subscription, query, options);
		return !handle.ready();
	}, deps);

	const upToDate = useTracker(() => {
		return Collection.findOne(query, options);
	}, deps);

	const found = Boolean(upToDate);

	const fields = {...init, ...upToDate};

	return {loading, found, fields};
};

export default makeFindOne;

The second one (note that here we receive the minimum amount of field updates and retain the last value of fields if the item gets deleted).

import {Meteor} from 'meteor/meteor';
import {useState, useEffect} from 'react';

const makeCachedFindOne = (Collection, subscription) => (
	init,
	query,
	options,
	deps
) => {
	console.debug({init, query, options, deps});

	const [loading, setLoading] = useState(true);
	const [found, setFound] = useState(false);
	const [fields, setFields] = useState(init);

	console.debug({loading, found, fields});

	useEffect(() => {
		setLoading(true);
		setFound(false);
		setFields(init);
		let current = init;

		let queryHandle;
		const handle = Meteor.subscribe(subscription, query, options, {
			onStop: (e) => {
				console.debug('onStop()', {e});
				if (queryHandle) queryHandle.stop();
			},
			onReady: () => {
				console.debug('onReady()');
				setLoading(false);
				queryHandle = Collection.find(query, options).observeChanges({
					added: (_id, upToDate) => {
						setFound(true);
						current = {...init, ...upToDate};
						setFields(current);
					},
					changed: (_id, upToDate) => {
						current = {...current, ...upToDate};
						setFields(current);
					},
					removed: (_id) => {
						setFound(false);
					}
				});
			}
		});

		return () => {
			console.debug('handle.stop()');
			handle.stop();
		};
	}, deps);

	return {loading, found, fields};
};

export default makeCachedFindOne;

Keep in mind the useEffect cleanup function will be run on every deps change. I suspect the handle.stop() in there’s the reason your subscription is being stopped. Perhaps it’s first called with empty deps or something, you’ll have to debug.

You should be able to do that simply, with one useTracker:

import {Meteor} from 'meteor/meteor';
import {useTracker} from 'meteor/react-meteor-data';

const useFindOne = (Collection, subscription) => (
	init,
	query,
	options
) => {
	const [ loading, upToDate ] = useTracker(() => {
		const handle = Meteor.subscribe(subscription, query, options);
		return [
                    !handle.ready(),
                    Collection.findOne(query, options)
                ];
	});

	const found = Boolean(upToDate);

	const fields = {...init, ...upToDate};

	return {loading, found, fields};
};

export default useFindOne;

Note though, you are going to have trouble if you try to accept query and options that way with deps because if they are defined inline, they will not have stable references.

You may also be interested in the new hooks in this PR:

I only see onStop(), e = undefined in the console. I do not see the handle.stop() debug message. Perhaps I can describe more accurately what happens in another message.

Thanks for the simplification suggestion. Is there a performance difference between that solution and using two useTracker hooks? I suppose that both useTracker solutions use the minimum amount of field updates under the hood even if findOne returns all fields on each update. To retain the last value of fields if the item gets deleted would be as simple as using an additional useState. I will make clear why I am interested in the callback solution in another message. Maybe this discussion is an instance of the XY problem.

There’s no real difference between the two in terms of Meteor overhead/performance, but useTracker uses a number of hooks internally, and if you use 2, you are double the number of hooks in your component. In practice, it probably isn’t that different, and you should never prematurely optimize. The best solution is the one that reads easiest. Code is for us, the humans, not for the computer.

I was thinking that maybe I could use the fact that queryHandle is undefined when onStop is called before onReady to force restart the subscription.

Here is another combination of custom publish/subscribe functions with which I am experiencing problems similar to the ones I have with makeCachedFindOne. The goal is to allow for queries whose results can be invalidated reactively (admittedly by hacking the stop() method). The use case is a full-text search feature where I want to be immediately informed on the client when the result set changes (or the connection drops).

Publish

const observeQuery = (QueriedCollection, resultsCollection) =>
	function (key, query, options, observe) {
		query = {
			...query,
			owner: this.userId
		};
		observe = {
			added: true,
			removed: true,
			...observe
		};
		const uid = JSON.stringify({
			key,
			query,
			options,
			observe
		});
		const results = [];
		let initializing = true;

		const stop = () => {
			this.stop();
		};

		const observers = {
			added: (_id, fields) => {
				if (initializing) results.push({_id, ...fields});
				else if (observe.added) stop();
			}
		};

		if (observe.removed) observers.removed = stop;
		if (observe.changed) observers.changed = stop;

		const handle = QueriedCollection.find(query, options).observeChanges(
			observers
		);

		// Instead, we'll send one `added` message right after `observeChanges` has
		// returned, and mark the subscription as ready.
		initializing = false;
		this.added(resultsCollection, uid, {
			key,
			results
		});
		this.ready();

		// Stop observing the cursor when the client unsubscribes. Stopping a
		// subscription automatically takes care of sending the client any `removed`
		// messages.
		this.onStop(() => handle.stop());
	};

export default observeQuery;

Subscribe/fetch hook.

import {Meteor} from 'meteor/meteor';
import {useState, useEffect, useRef} from 'react';

const makeObservedQuery = (Collection, subscription) => (
	query,
	options,
	deps
) => {
	const [loading, setLoading] = useState(true);
	const [results, setResults] = useState([]);
	const [dirty, setDirty] = useState(false);
	const handleRef = useRef(null);

	useEffect(() => {
		setDirty(false);

		const timestamp = Date.now();
		const key = JSON.stringify({timestamp, query, options});
		const handle = Meteor.subscribe(subscription, key, query, options, {
			onStop: () => {
				if (handleRef.current === handle) setDirty(true);
			},
			onReady: () => {
				const {results} = Collection.findOne({key});
				setLoading(false);
				setResults(results);
			}
		});
		handleRef.current = handle;

		return () => {
			handle.stop();
		};
	}, deps);

	return {loading, results, dirty};
};

export default makeObservedQuery;

I did not try to make it completely reactive from the beginning since full-text search is not supported with classic pub/sub, and eventually I realized it made much more sense to show the user a snapshot of the results with a reset button that is only shown when new results are available while at the same time using relatively low resources. Of course if one does not care about hiding the refresh button, for instance if the result set changes too often or fresh results are not that interesting one could use a single method and be done.