Juggling Vue.js Refs
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.