Abdelrahman Awad

Invalidating Computed with The Composition API

Published Jan 31, 2021 |

There are cases where you want to invalidate a computed prop you created and re-evaluate its value, this happens often if the computed prop uses non-reactive parts.

The problem

I've encountered a problem recently when I was creating a feed-like listing displaying the timestamp in a Twitter-like fashion, so "a few seconds ago", "5m", "14h", and so on.

A tweet image highlighting the "5 minutes ago" timestamp

Assuming the dates are epoch timestamps, and for simplicity's sake, we will only show time difference in minutes.

You probably will end up with something like this:

import { computed } from 'vue';
import { differenceInMinutes } from 'date-fns';

export default {
  props: {
    createdAt: {
      type: Number,
      required: true,
    },
  },
  setup(props) {
    const dateTimeDiff = computed(() => {
      return differenceInMinutes(Date.now(), props.createdAt);
    });

    return {
      dateTimeDiff,
    };
  },
};

That works fine, but only for the initial render, and unless the createdAt prop changes (highly unlikely), the generated difference will remain the same and won't update as time goes on.

This is because while the props are reactive, the other part you rely on is the Date.now, which is not reactive.

If you check Twitter, you will notice that the timestamp updates every minute if it's fairly recent. So how can we do that?

Naive Approach

There is no doubt we will need a timer to run and update the time diff every minute, but you may be tempted to ditch computed properties and use refs:

import { ref } from 'vue';
import { differenceInMinutes } from 'date-fns';

const dateTimeDiff = ref(differenceInMinutes(Date.now(), props.createdAt));
setInterval(() => {
  dateTimeDiff.value = differenceInMinutes(Date.now(), props.createdAt);
}, 1000 * 60);

This would work fine, but we are talking about a feed here. It will have hundreds of items and you would be scheduling hundreds of timers. So this isn't the optimal way to do this, how can we do this efficiently?

How computed works

Before I mention the solution, let's review quickly how a computed property works in Vue.

It works in the same way it did in Vue 3 more or less. Here is a simple computed prop:

import { computed, ref } from 'vue';

const count = ref(0);
const double = computed(() => count.value * 2);

double.value; // 0
count.value++;
double.value; // 2
  • Once you get the computed value with .value you invoke the getter function you defined and caches the return value.
  • When the getter function runs, it checks if it invoked the getters of reactive properties. In the example, it's the count ref.
  • These reactive properties are then collected as a "dependency" for that computed property and tracked for further changes.
  • Once any of the reactive properties change, the computed property cache will be invalidated and the getter will re-run.

It's not black magic, even though Vue makes it certainly magical, once you understand the process you can abuse that fact for your needs and I have wrote an article on that.

A quick tip: you can use any debugging tools to figure out when a computed property is invalidated and when the getter is re-run.

A simple console.log is sufficient to understand the dependency collection process.

import { computed, ref } from 'vue';

const count = ref(0);
const anotherCount = ref(0);

const doubleOrQuad = computed(() => {
  console.log('Running!');
  if (count.value <= 5) {
    return count.value * 2;
  }

  return anotherCount.value * 4;
});

doubleOrQuad.value; // => 0 and logs: Running!
count.value++;
doubleOrQuad.value; // => 2 and logs: Running!

anotherCount.value++;
doubleOrQuad.value; // => 2 and No logs

When you update the count ref, the computed prop will invalidate its value and re-compute it accordingly.

However, if you try changing anotherCount it will never invalidate it and it won't re-compute the value. Only when count is 6, only then it will start reacting to anotherCount mutations.

To explain why does it work like that, always remember that it's not black magic. There is a process behind it, and the process here is called dependency collection. Let's go step by step:

  • When you define the computed prop and attempt to get its value, the getter will run and logs "Running!" into the console.
  • When the getter runs, it executes just like any JavaScript code one instruction at a time, so first it encounters the if statement.
  • The if statement then executes by checking the reactive reference count's value and comparing it to 5, because we just got the value of a reactive value it just got added to the tracked dependencies of this computed prop.
  • The function exits with the count reference multiplied by 2.

In no way the computed getter function is aware of the other code path because computers are dumb. The anotherCount was never collected as a reactive dependency, thus any mutations to the anotherCount has no effect on the computed prop value.

Once the count.value is greater than 5, then it will go differently:

  • The getter runs again due to count.value changes, starts from top to bottom encountering the if statement.
  • If statement compares the value of the count ref to 5, count is still a reactive dependency because its getter was invoked just now for the comparison.
  • The comparison fails and the code moves to the other path.
  • It encounters anotherCount reactive ref and gets its value and multiplies it by 4, now the anotherCount is collected as a reactive dependency to the computed prop.

Now both count and anotherCount are now collected as reactive dependencies and any further changes to either of them will invalidate the computed prop cache.

Even if you are not using the count value anymore, as long as the code execution "saw" it, it will be tracked.

Solving the date diff reactivity

So how can we use that knowledge to our advantage to solve this problem efficiently?

It's simple, we just need something reactive that invalidates the computed prop every minute.

If you caught on with the last example, you might've guessed that it's not something reactive in particular. But rather anything reactive will do, any ref will do!

import { ref, computed } from 'vue';
import { differenceInMinutes } from 'date-fns';

// cache invalidation ref
const count = ref(0);

const dateTimeDiff = computed(() => {
  count.value; // Just invoke the getter

  return differenceInMinutes(Date.now(), props.createdAt);
});

// increment every minute
setInterval(() => {
  // invalidates the computed prop
  count.value++;
}, 1000 * 60);

That does what we need, it invalidates the computed prop value once every minute because the collected reactive dependency count is changing every minute.

Notice that just accessing the .value prop is enough to collect it as a dependency, you don't have to do assignments or return the value. The code is dumb, once a getter was invoked for reactive dependency it is collected regardless of what you do with it.

Think of the count ref here as our backdoor, it gives us indirect ability to invalidate the computed prop cache and force it to re-evaluate when we want. In that case, it's every minute because of setInterval.

While this is cool, it is still not efficient. You are still creating a timer for every single feed item which is not ideal for the feed example.

Making it efficient

To make this efficient, a little bit of refactoring is in order. Let's start by creating a generic useComputedWithTtl function that encapsulates the behavior above.

composition/timers.js
import { ref, computed } from 'vue';

export function useComputedWithTTL(getter, ttl) {
  const count = ref(0);
  const prop = computed(() => {
    count.value; // access the count

    return getter(); // compute whatever we wanted to compute
  });

  // setup invalidation timer
  const interval = setInterval(() => {
    count.value++;
  }, ttl);

  // Cleanup the interval
  onBeforeUnmount(() => {
    clearInterval(interval);
  });


  return prop;
}

Then you can use the new useComputedWithTtl like this:

import { differenceInMinutes } from 'date-fns';
import { useComputedWithTtl } from '~/composition/timers';

export default {
  props: {
    createdAt: {
      type: Number,
      required: true,
    },
  },
  setup(props) {
    const dateTimeDiff = useComputedWithTtl(() => {
      return differenceInMinutes(Date.now(), props.createdAt);
    }, 60 * 1000);

    return {
      dateTimeDiff,
    };
  },
};

Now that looks and feels great to use now, this is fine for many use-cases and applications when you have non-repeating data that you want to computed based on a time value.

However it's not the optimized final form for our specific example, we would still be creating a timer for each feed item which is not ideal.

To help with that, we need to make a couple of assumptions:

  • We only want to update the time diff once every 1 minute.
  • We don't care much about time accuracy, that it is fine if we are off by 30 seconds on average.

With these two assumptions, we can safely conclude that a single timer would work well for all items.

In other words, we will create a single timer that invalidates all the computed props cache for all the feed items, which also means only a single count ref is needed.

A quick tip: One of the cool stuff about ref is that it doesn't have to be called within the setup function, so we can safely create singleton refs that exist only once for the lifetime of the application.

Let's create a slightly different version that makes use of these assumptions:

composition/timers.js
import { ref, computed } from 'vue';

// no longer specific to a component
const count = ref(0);

// global interval
let interval;

// Non-configurable shared TTL
const SHARED_TTL = 60 * 1000;

export function useComputedWithMinuteTtl(getter) {
  const prop = computed(() => {
    count.value; // access the count

    return getter(); // compute whatever we wanted to compute
  });

  if (!interval) {
    interval = setInterval(() => {
      count.value++;
    }, SHARED_TTL);
  }

  return prop;
}

By sacrificing the configurability of the TTL value, we were able to reduce the number of timers to just 1 for the entire app. The new useComputedWithMinuteTtl function caches the computed getter for exactly 1 minute before it invalidates it.

That's pretty good for our use-case and your users will notice the almost-accurate time updates.

You can modify this to have some configurability for the TTL value by sharing a single timer/count-ref pairs for each unique TTL value.

You can see that in action here, wait a minute and watch the time indicators update.

Conclusion

Invalidating the computed prop cache is just a matter of understanding how reactivity works in Vue.js and how the dependency tracking works.

By integrating invalidation refs into the computed props we can re-compute the value on-demand by changing the invalidation ref value, it can be as simple as a simple counter.

I have a few more articles on the composition API lined up, if you are interested you can follow me on Twitter or sign up for the newsletter.

Thanks for reading 👋

Join The Newsletter

Subscribe to get notified of my latest content

You might also like