Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

Tips for Reactivity in Vue

Optional Reactivity#

import { 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);
isRef(raw); // false
isRef(reffed); // true
unref(raw); //  1
unref(reffed); //  1

Example:

import { 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;
}
import { 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)
);

Avoid repacking refs#

import { 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:

import { 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 the aforementioned statements yield a reactive id value from a reactive object value, or any object ref. However, that creates an issue. The user has to structure the reactive data appropriately before passing it on to the function, which forces them to use any of the packing mechanisms available.

The fundamental problem is that this process compels the consumer to unpack their values and repack them again if they wish to sustain reactivity. Such a practice often leads to increased verbosity, which I like to call "repacking refs."

Solutions#

import { 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);
import { unref } from 'vue';
export function unravel<T>(value: MaybeLazyRef<T>): T {
  if (typeof value === 'function') {
    return value();
  }
  return unref(value);
}
export function isWatchable<T>(
  value: MaybeLazyRef<T>
): value is LazyOrRef<T> {
  return isRef(value) || typeof value === 'function';
}

Example:

import { 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;
}
import { 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);

Require reactivity#

Moving away from our prior examples, let's say you're attempting to construct a usePositionFollower function that solely accepts reactive position arguments indicated in x,y coordinates. Obtaining a non-reactive position would be pointless since there wouldn't be anything to follow.

As a result, it would be beneficial to inform the user to only provide reactive values, or more specifically, reactive expressions. This rules out the usage of MaybeLazyRef, but keep in mind we had previously created LazyOrRef for this reason, which can operate alongside the isWatchable function.

import { 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;
}
const { x, y } = useMouse();
const Follower = usePositionFollower(() => ({
  x: x.value,
  y: y.value,
}));

References:
https://logaretm.com/blog/juggling-refs-around/

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.