When you build a composable API in Vue.js you don’t usually think about the ergonomics of it. Especially if you have other developers in your team or plan to open-source your API for everybody. But having worked for so long with the composition API exclusively I found some simple yet significant improvements around passing refs that can be useful for you to use in your next app.

What is “Passing Refs”

To get a better idea of what I mean by “passing refs”. Imagine you have a useProduct composable that accepts a product id to fetch it.

tsimport { ref, onMounted } from 'vue';

async function fetchProduct(id: number) {
  // returns products from API
  return fetch(`/api/products/${id}`).then((res) => res.json());
}

export function useProduct(id: number) {
  const product = ref(null);
  onMounted(async () => {
    product.value = await fetchProduct(id);
  });

  return product;
}

While having a function like this is very neat for encapsulating that logic, it is not very flexible because the id value can only be initiated once and only once. If it changes, the product won’t refetch.

We can fix that by allowing the id value to be a reactive ref instead and watching for changes.

tsimport { watch, ref, Ref, onMounted } from 'vue';

async function fetchProduct(id: number) {
  // returns products from API
  return fetch(`/api/products/${id}`).then((res) => res.json());
}

export function useProduct(id: Ref<number>) {
  const product = ref(null);
  onMounted(async () => {
    product.value = await fetchProduct(id.value);
  });

  watch(id, async (newId) => {
    product.value = await fetchProduct(newId);
  });

  return product;
}

Much better, and it looks good but let’s see how ergonomic that is when being used by a consumer:

tsimport { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useProduct } from '@/composables/products';

const route = useRoute();

const product = useProduct(
  computed(() => route.params.productId)
);

This is completely fine, however, there are a few ergonomic issues with it. This is what this article is going to address.

1. Make reactivity optional

First off, what if the consumer is perfectly fine with not having the value watched? Requiring a ref may seem a bit aggressive, but here is the problematic scenario. And by “problematic” I mostly mean “annoying to use”.

tsimport { useProduct } from '@/composables/products';

// ❌ will error out because the raw value "1" isn't reactive.
const product = useProduct(1);

Instead, let’s make it optional by utilizing a very cool utility type used by a lot of Vue libraries, I have mentioned it before in a previous article but as a refresher here is the MaybeRef<T> type:

tsimport { Ref, ref } from 'vue';

type MaybeRef<T> = Ref<T> | T;

// example usage
// ✅ Valid
const raw: MaybeRef<number> = 1;
// ✅ Valid
const reffed: MaybeRef<number> = ref(1);

Heads up

Vue 3.3 added the MaybeRef as a built-in type, so you can import it directly from vue and avoid defining it.

Now that we can declare arguments as optionally reactive, we need a way to extract the real value regardless if it is a ref or not. Luckily Vue exports an unref function that makes this a breeze.

tsimport { ref, unref } from 'vue';

// example usage
// ✅ Valid
const raw: MaybeRef<number> = 1;
// ✅ Valid
const reffed: MaybeRef<number> = ref(1);

unref(raw); //  1
unref(reffed); //  1

Lastly, since we watch the values using Vue’s watch() API we need to know if the passed value is a ref or not so we can set up a watcher or skip it if it is a raw value. Again Vue has an isRef function that tells you just that.

tsimport { isRef, ref } from 'vue';

const raw: MaybeRef<number> = 1;
const reffed: MaybeRef<number> = ref(1);

isRef(raw); // false
isRef(reffed); // true

Using these few ideas we end up with the following improvements for our useProduct function:

tsimport { unref, ref, isRef, onMounted, watch } from 'vue';
import { MaybeRef } from '@/types';

async function fetchProduct(id: number) {
  // returns products from API
  return fetch(`/api/products/${id}`).then((res) => res.json());
}

export function useProduct(id: MaybeRef<number>) {
  const product = ref(null);
  onMounted(async () => {
    product.value = await fetchProduct(unref(id));
  });

  if (isRef(id)) {
    watch(id, async (newId) => {
      product.value = await fetchProduct(newId);
    });
  }

  return product;
}

Now it is up to the consumer to decide how to use that function, if they plan to fire it off just once then they can pass the raw id as a number and if they expect it to be in sync with a given id then they should pass it as a ref.

tsimport { useProduct } from '@/composables/products';
import { computed } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute();

// Fetched only once on mount
const product = useProduct(route.params.productId);

// In sync whenever the param changes
const product = useProduct(
  computed(() => route.params.productId)
);

It’s no wonder that many libraries define the very same MaybeRef or MaybeReactive types inside their codebase to improve their flexibility. To name a few: vee-validate, villus and @vueuse/core and many others.

2. Avoid repacking refs

You may have already noticed the second source of “annoyance” that we want to address. Passing reactive refs is not straightforward especially if they are extracted from reactive or ref objects. This last statement might’ve confused you so let us go back to some composition API caveats:

tsimport { reactive, watch } from 'vue';

const obj = reactive({
  id: 1,
});

// ✅ Works!
watch(obj, () => {
  // ...
});

// ❌ Doesn't work
watch(obj.id, () => {
  // ...
});

The last watch doesn’t quite work because when you access props.id you get a non-reactive version of it, this is the raw prop value. So to keep it reactive you may resort to a few techniques:

tsimport { reactive, watch, toRef, toRefs, computed } from 'vue';

const obj = reactive({
  id: 1,
});

// converts all entries to refs
const objRef = toRefs(obj);
watch(objRef.id, () => {
  //...
});

// You can also destruct it
const { id: idRef } = toRefs(obj);
watch(idRef, () => {
  //...
});

// convert a single entry to reactive version of it
const idRef = toRef(obj, 'id');
watch(idRef, () => {
  //...
});

// just extract the value in a computed prop
const idComp = computed(() => obj.id);
watch(idComp, () => {
  //...
});

All of the previous statements give you a reactive id value out of a reactive object value, or any object ref as well. But that’s the problem, for the reactive value to be passed to your function the user needs to pack it neatly in a reactive format, so they are forced to use any of those packing mechanisms.

The main issue here is you are forcing the consumer to unpack their values and repack them again if they want to preserve reactivity. This often increases verbosity, I call this “repacking refs”.

This is more common than you think since the main sources of packed values are either props or route parameters. Usually, you will have this in your component:

tsimport { useRoute } from 'vue-router';

// reactive props object
const props = defineProps<{
  id: number;
}>();

const route = useRoute();
// reactive route params
route.params;

And it will have the same shortcomings when trying to pass some of their properties to your composable function.

However there is a neat trick that you can use with watch and it has been around since Vue 2 days as well, using getters:

tsimport { watch } from 'vue';
import { useRoute } from 'vue-router';

const props = defineProps<{
  id: number;
}>();

// ✅ Works!
watch(
  () => props.id,
  () => {
    // ...
  }
);

const route = useRoute();
// ✅ Works!
watch(
  () => route.params.id,
  () => {
    // ...
  }
);

The cool thing about this is you can easily put whatever you want in your getter function. You can extract props, combine props, or compute a value. After all this one core aspect of Vue’s reactivity.

Now how can this help us in our quest? First, let’s improve the MaybeRef type by allowing getter functions. It’s a couple of types, LazyOrRef and MaybeLazyRef. Feel free to find better names.

tsimport { Ref } from 'vue';

// Raw value or a ref
export type MaybeRef<T> = Ref<T> | T;

// Can't be a raw value
export type LazyOrRef<T> = Ref<T> | (() => T);

// Can be a ref, a getter, or a raw value
export type MaybeLazyRef<T> = MaybeRef<T> | (() => T);

Heads up

Vue 3.3 added the MaybeRefOrGetter as a built-in type which is exactly the same as the MaybeLazyRef type we’ve defined above.

Secondly, we need a way to extract the raw value from it. Since unref won’t be helpful here, we should come up with our own function. I like to call it unravel, it is straightforward since we just need to detect if the passed value is a function.

Heads up

Vue 3.3 added the toValue helper which you can import in your code, it has the same functionality as the unravel function we’ve defined below.

tsimport { unref } from 'vue';

export function unravel<T>(value: MaybeLazyRef<T>): T {
  if (typeof value === 'function') {
    return value();
  }

  return unref(value);
}

Heads up

I must mention that this might cause problems if you are passing reactive functions, so another layer of checks might be necessary in that case.

Third, we need a way to detect if the passed value can be watched or not. Similar to isRef except it will also include that function type check.

tsexport function isWatchable<T>(
  value: MaybeLazyRef<T>
): value is LazyOrRef<T> {
  return isRef(value) || typeof value === 'function';
}

The value is LazyOrRef makes this function useful as it tells Typescript to reduce the possible types when this function is evaluated to true.

Lastly, let’s bake this all in into our useProduct function:

tsimport { ref, onMounted, watch } from 'vue';
import { unravel, isWatchable } from '@/utils';
import { MaybeLazyRef } from '@/types';

async function fetchProduct(id: number) {
  // returns products from API
  return fetch(`/api/products/${id}`).then((res) => res.json());
}

export function useProduct(id: MaybeLazyRef<number>) {
  const product = ref(null);
  onMounted(async () => {
    product.value = await fetchProduct(unravel(id));
  });

  if (isWatchable(id)) {
    // Works because both a getter fn or a ref are watchable
    watch(id, async (newId) => {
      product.value = await fetchProduct(newId);
    });
  }

  return product;
}

Now check how a consumer of this function can easily pass reactive expressions in without extra unpacking and packing:

tsimport { useRoute } from 'vue-router';
import { useProduct } from '@/composables/products';

const route = useRoute();

// Fetched only once on mount
const product = useProduct(route.params.productId);

// In sync whenever the param changes
const product = useProduct(() => route.params.productId);

You can notice this pattern emerging with a lot of libraries, notably villus and @vueuse/core are using them for a lot of resumable APIs they expose.

3. Requiring reactivity and when to do so

The previous tips approached how to improve certain aspects of using refs as arguments. But a quick thing you can also utilize is to expose intent through your argument types.

Straying away from our previous product examples. Let’s assume you want to build a usePositionFollower function that can only accept reactive position argument denoted in x,y coordinates. There is no point in receiving a non-reactive position because there won’t be anything to follow.

So you would like to let the consumer know to only give you reactive values. Or rather reactive expressions. So this means MaybeLazyRef won’t work well for us, but remember that we also created LazyOrRef for that purpose to use with the isWatchable function.

Here is what usePositionFollower() might look like:

tsimport { h, defineComponent } from 'vue';
import { LazyOrRef } from '@/types';
import { unravel } from '@/utils';

export function usePositionFollower(
  position: LazyOrRef<{ x: number; y: number }>
) {
  const style = computed(() => {
    const { x, y } = unravel(position);

    return {
      position: 'fixed',
      top: 0,
      left: 0,
      transform: `translate3d(${x}px, ${y}px, 0)`,
    };
  });

  const Follower = defineComponent(
    (props, { slots }) =>
      () =>
        h('div', { ...props, style: style.value }, slots)
  );

  return Follower;
}

The previous example might be unusual since it returns a component but it is a very useful pattern to return a component in your composition functions.

Now, this can be used with the famous useMousePosition composable that we can see in the Vue docs.

tsconst { x, y } = useMouse();

const Follower = usePositionFollower(() => ({
  x: x.value,
  y: y.value,
}));

Note how all our previous improvements made passing the position much easier.

Here is this example in action:

Conclusions

I think those three tips may come in handy, especially since the Vue ecosystem seems to be using them more and more. These can make it so much easier to work with your composable APIs and reduce the verbosity that may come with it. Your consumers will appreciate these.

If you liked this article feel free to let me know, I might end up making a series of articles on these small yet effective bits of the composition API.

Join The Newsletter

Subscribe to get notified of my latest content