Better hooks? useSubscription and useCursor

useTracker is great, but what do you think about these? Neither of these more specialized hooks uses a computation or tracker:

import { Mongo } from "meteor/mongo"
import { useEffect, useMemo, useReducer, useRef } from "react"
import { Meteor } from "meteor/meteor"

const fur = (x: number): number => x + 1
const useForceUpdate = (): (..._:any[]) => void => {
  const [, forceUpdate] = useReducer(fur, 0)
  return forceUpdate
}

export const useSubscription = (name: string, ...args: any[]) => {
  const forceUpdate = useForceUpdate()
  const subscription = useRef<Meteor.SubscriptionHandle>({
    stop() {},
    ready: () => false
  })
  useEffect(() => {
    subscription.current = Meteor.subscribe(name, ...args, {
      onReady: forceUpdate,
      onStop: forceUpdate
    })
    return () => {
      subscription.current.stop()
    }
  }, [name /* ...args */])
  return subscription.current
}

export const useCursor = <T>(factory: () => Mongo.Cursor<T>) => {
  const cursor = useMemo(factory, [])
  const forceUpdate = useForceUpdate()
  useEffect(() => {
    const observer = cursor.observeChanges({
      added: forceUpdate,
      changed: forceUpdate,
      removed: forceUpdate
    })
    return () => {
      observer.stop()
    }
  }, [cursor])
  return cursor
}

Usage:

  const subscription = useSubscription('messages', roomId)
  const messages = useCursor(() => Messages.find({ roomId }, { sort: { createdAt: 1 } }))

There are some details to work out with subscription deps, but these work pretty well as is. Pure, and ready for concurrent mode.

10 Likes

Nice! I prefer those, since it makes use of React strength.

I will give them a try

:+1:

I have done Zero performance measuring. I’m curious if there is a difference. Conceptually it should be doing a bit less - iterating the cursor only once per render, instead of once for the fetch, and another on the resulting array. But I don’t know.

I do know, the implementation is WAY simpler than useTracker, and much less flexible/general purpose.

I’m also imagining an iterator component, which would set up observers on individual documents, instead of the entire collection, to try and reduce React reconciliation - the thinking is that it could reduce some overhead for very large collection lists. It’s a ton of effort though - not worth it without measuring the need first.

4 Likes

Interesting - I guess useTracker / Tracker reactivity brings a generic “refreshes whenever relevant” API that works for all reactive sources. Some reactive datasource happen to provide the direct callbacks (onChange, observeChanges…) in addition to Tracker integration, so for those you can indeed build tailor-made hooks that trigger a forceUpdate through those source-specific callbacks rather than through the generic Tracker. Do you think avoiding the extra Tracker.computation is really worth the cognitive overhead though ?

No, I don’t think avoiding Tracker alone is reason enough to use these. A computation actually has very little overhead, and useTracker has a complex implementation, but isn’t all that inefficient either (though it does get messy with concurrent mode and all the timeout management - it’s still better than some other platform integrations).

I was after something else - I wanted to reduce the number of redraws and iterations over the results set, and then secondarily explore some options for further optimizations. This useCursor hook returns the cursor directly, and iterates through that in React, rather than dumping everything to an array, then iterating it again in React. (I didn’t measure the performance impact of that, like I said.) I also have some ideas to maintain a immutable list using observeChanges hook, so that all the children can be set up with memo and reduce some reconciliation overhead. Alternatively, I was thinking of some way to use a custom iterator component, which would set up change observations per document, and avoid a lot of reconciliation (I can’t think of a viable way to make this work though that doesn’t have MORE overhead, than just using memo on an immutable list, because I’d have to maintain observers for the whole list for added and removed, then one each for every document on changed - I don’t know if its worth it).

All of this could probably done with useTracker and some extra data structures - but it’s overhead. The other benefit of these hooks though is that they are very simple compare with useTracker. These are completely easy to read and understand. useTracker is, less so… Sadly, useTracker being general purpose really can’t be any simpler. These can be simpler because they are so task specific. The useSubscription hook for example will avoid any kind of chance for creating side effects in first render in a very clear and obvious way. I think it’s similarly clear for useCursor without any kind of hackery. These are very straight forward.

Here’s an update BTW, with some similar hooks for user APIs. I changed useSubscription to use a factory, so I can have a clean way to specify deps. IMHO, deps are the weak point of hooks in general…

import { Meteor } from 'meteor/meteor'
import { Mongo } from 'meteor/mongo'
import { Tracker } from 'meteor/tracker'
import { useEffect, useMemo, useReducer, useRef, useState } from 'react'

const fur = (x: number): number => x + 1
const useForceUpdate = (): (..._:any[]) => void => {
  const [, forceUpdate] = useReducer(fur, 0)
  return forceUpdate
}

export const useSubscription = (factory: () => Meteor.SubscriptionHandle, deps: ReadonlyArray<any> = []) => {
  const forceUpdate = useForceUpdate()
  const subscription = useRef<Meteor.SubscriptionHandle>({
    stop() {},
    ready: () => false
  })

  useEffect(() => {
    subscription.current = factory()
    const computation = Tracker.autorun(() => {
      if (subscription.current.ready()) forceUpdate()
    })

    return () => {
      computation.stop()
    }
  }, deps)
  return subscription.current
}

export const useCursor = <T>(factory: () => Mongo.Cursor<T>, deps: ReadonlyArray<any> = []) => {
  const cursor = useMemo(factory, deps)
  const forceUpdate = useForceUpdate()
  useEffect(() => {
    const observer = cursor.observeChanges({
      added: forceUpdate,
      changed: forceUpdate,
      removed: forceUpdate
    })
    return () => {
      observer.stop()
    }
  }, [cursor, ...deps])
  return cursor
}

export const useUser = () => {
  const [user, setUser] = useState(() => Meteor.user())
  useEffect(() => {
    const computation = Tracker.autorun(() => {
      setUser(Meteor.user())
    })
    return () => computation.stop()
  }, [])
  return user
}

export const useUserId = () => {
  const [user, setUser] = useState(() => Meteor.userId())
  useEffect(() => {
    const computation = Tracker.autorun(() => {
      setUser(Meteor.userId())
    })
    return () => computation.stop()
  }, [])
  return user
}

Actually, that iterator idea might make space for a certain type of optimization. If the main pub/sub only contains the ID of the document in this list, that should reduce the merge box overhead. The observer can be set up for all 3 event types (updated, added, and removed), then in each iterator component, if they receive the update change, they could fetch their own copy of the document over methods. That actually can be done with the hook as is, no need for additional tooling. Neat.

:+1: Nice! Nice! Nice!

@captainn :
Sure, the code of useEffect() is fairly complex, so simpler api-specific implementations when possible are kind of nice. Still not sure that more code (even if simpler than the current code) and more API is worth it if it brings no meaningful perf or functionnal gains.

The cursor / iterator improvements you mention sound interesting though - and yeah, having a way to get immutable data from minimongo is something that is sorely missing IMO, would be a huge perf improvement. Unfortunately I think this would have to be done within minimongo when processing “changed” ddp messages, otherwise diffing the whole docs for changes on read seems like a pretty bad performance hit.

Also, I’d be mindful that for instance your useSubscription will unconditionally rerender on ready(), which in most cases is something I’m not interested in - subscribe and then just find() is generally enough. In some specific cases I do need to react on ready() specifically, but that is quite rare, and useEffect() lets me control exactly what triggers reactivity. Maybe useSubscription() should take a param to let the caller control that ?

I don’t think useSubscription would cause any more render churn than normal, except in non-standard situations. In a combined useTracker with both subscribe and find, you’d theoretically get both the ready() redraw, and the reactive .find() redraw at the same time - but wouldn’t the same be true for two instances of useTracker - or the above alternative hooks? I’m assuming all these events would fire in the same stack frame, because I think there’s an assumption that they do anyway if both used inside useTracker. But I haven’t tested that.

Expanding the deps array to allow for a config object could allow for that, but it’d be relatively easy to work around - just call Meteor.subscribe() in useEffect with appropriate deps, and a cleanup method to stop the subscription.

useEffect(() => {
  const subscription = Meteor.subscribe('stuff', args);
  return () => subscription.stop();
}, [args]);

One of the thing the React team keeps driving for is to make render method so cheap, they can be run, rerun, and tossed away without worry. That’s the entire idea behind concurrent mode, and something to keep in mind.

To that end, I think a useCursor implementation with an immutable contract gets us the best mix of ease of implementation and high performance (when iterator components are properly memoized). I think it should be relatively easy to set up immutability using observeChanges since that only reports the changed keys, not the full document - no diffing needed. It’d then just be a matter of orchestrating object updates in a local array, and making sure references are broken as needed.