19

I am upgrading an app from vue 2 to vue 3 and I am having some issues with composables. I'd like to use props in the composable but it doesn't seem to be working. The code sample is pulled from a working component and works fine when I leave it in the component.

I assume defineProps isn't supported by composables, but then I am unclear how to handle it. When I pass the src in the parameters it loses its reactivity.

// loadImage.js
import { defineProps, onMounted, ref, watch } from 'vue'

// by convention, composable function names start with "use"
export function useLoadImage() {
  let loadingImage = ref(true)
  let showImage = ref(false)
  const props = defineProps({
    src: String,
  })
  const delayShowImage = () => {
    setTimeout(() => {
      showImage.value = true
    }, 100)
  }
  const loadImage = (src) => {
    let img = new Image()
    img.onload = (e) => {
      loading.value = false
      img.onload = undefined
      img.src = undefined
      img = undefined
      delayShowImage()
    }
    img.src = src
  }
  onMounted(() => {
    if (props.src) {
      loadImage(props.src)
    }
  })
  watch(
    () => props.src,
    (val) => {
      if (val) {
        loadingImage.value = true
        loadImage(val)
      }
    },
  )
  // expose managed state as return value
  return { loadingImage, showImage }
}

Edit

This method worked for me, but the two methods mentioned in the comments below did not.

I have a new question here.

// loadImage.js
import { onMounted, ref, watch } from 'vue'

// by convention, composable function names start with "use"
export function useLoadImage(props) {
  let loadingImage = ref(true)
  let showImage = ref(false)

  const delayShowImage = () => {
    setTimeout(() => {
      showImage.value = true
    }, 100)
  }
  const loadImage = (src) => {
    let img = new Image()
    img.onload = (e) => {
      loading.value = false
      img.onload = undefined
      img.src = undefined
      img = undefined
      delayShowImage()
    }
    img.src = src
  }
  onMounted(() => {
    if (props.src) {
      loadImage(props.src)
    }
  })
  watch(
    () => props.src,
    (val) => {
      if (val) {
        loadingImage.value = true
        loadImage(val)
      }
    },
  )
  // expose managed state as return value
  return { loadingImage, showImage }
}

<script setup>
import { defineProps, toRef } from 'vue'
import { useLoadImage } from '../../composables/loadImage'

const props = defineProps({
  src: String
})
const { loading, show } = useLoadImage(props)

</script>
2
  • How exactly does it lose reactivity? Please, clarify what is the current behaviour and what you expect. Notice that you use script setup, not regular composition api. There is no src variable so return { loadingImage, showImage, src } will cause an error Commented May 27, 2022 at 16:45
  • @EstusFlask The src was a typo from an earlier version, I removed it. I was calling const {loadingImage, showImage} = useLoadImage(props.src) and src would not update once the property was set. passing in the entire props object solved the issue.
    – whoacowboy
    Commented May 27, 2022 at 16:57

3 Answers 3

44

Your assumption is correct that defineProps cannot be used in composables! But the question is:

How to pass props into composables without losing reactivity:

❓ Pass the whole props object

const props = defineProps({ src: string })

useFeature(props)

If you pass the whole props object, reactivity will be retained! However, I don't recommend doing that because:

  1. The composable doesn't need all the props
  2. If it needs ALL the props then probably you should split that composable into smaller ones

In general keep your composables as simple as they can be

❓ Use toRef

One solution people use, is toRef:

const props = defineProps({ foo: Object })

useFeature(toRef(props, 'foo'))

This might work in most cases, however there are two problems:

  1. props.foo may not exist when toRef is called
  2. This cannot handle the case when props.foo is swapped to a different object.

❓ Use computed

This is the most common solution devs use:

const props = defineProps({ foo: Object })

useFeature(computed(() => props.foo?.bar))

However, using computed is sub-optimal here. Internally, computed creates a separate effect to cache the computation. computed is an overkill for simple getters that just access properties.

❓✅ Use toRefs

const props = defineProps({ src: string })

const { src } = toRefs(props)
useFeature(src)

This works very well but starting from 3.3 we would have reactive defineProps so it would be unnecessary to use toRefs on props.

I would think of it as a legacy code starting from 3.3.

✅ Use "thunking"

The least expensive way to pass non-ref reactive state into a composable is by wrapping it with a getter (or "thunking" - i.e. delaying the access of the actual value until the getter is called):

const props = defineProps({ foo: Object })

useFeature(() => props.foo?.bar)

In this way, reactivity will be retained! Here is an example on how to use this inside composables:

import { computed, watch } from 'vue'

export function useFeature(imageSrc) { 
  const newImageSrc = computed(() => `https:\\${imageSrc()}`) // 👈 access it

  watch(imageSrc, (newVal) => { ... } // 👈 watch it
  
  return { ... }
}

Checkout a demo in Vue SFC Playground

Future improvements

In this PR, from which this answer is heavily inspired, we will have the ability to use toRef with a getter syntax like:

toRef(() => object.key)

So when 3.3 is released the best way to do it will be:

✅✅ Use toRef with a getter

const props = defineProps({ foo: Object })

useFeature(toRef(() => props.foo?.bar))
1
  • Masterclass on how to answer a question. Worth mentioning I was looking for how to pass props to useQuery of @tanstack/vue-query, and this is exactly how! Commented Apr 19 at 10:50
20

According to official docs :

defineProps and defineEmits are compiler macros only usable inside <script setup>

You should pass the props as parameter without destructing them to not lose the reactivity :

export function useLoadImage(props) {
....

}
11
  • 2
    Thank you that worked. I was calling const {loadingImage, showImage} = useLoadImage(props.src) and that did not work. Passing the props object worked well. Thank you.
    – whoacowboy
    Commented May 27, 2022 at 16:46
  • 2
    It's an antipattern to pass the whole object that can be accessed in arbitrary way. A more correct way is to make useLoadImage accept a ref, i.e. srcRef = computed(() => props.src) Commented May 27, 2022 at 16:48
  • 1
    That's a cleaner way specially for parameters typing, but I see in Vuetify 3 code source they pass the whole props to composables github.com/vuetifyjs/vuetify/blob/next/packages/vuetify/src/… Commented May 27, 2022 at 16:53
  • 1
    @BoussadjraBrahim Yes, that's an example of questionable code. When a hook changes the way it works with props, it needs extra care for all places where they are passed, and vice versa. But Vuetify uses TS so it makes it's easier to maintain. Commented May 27, 2022 at 17:03
  • 1
    @BoussadjraBrahim I use computeds where possible to avoid accidental writes to readonly properties, but yes, that's a matter of taste most times. Commented May 27, 2022 at 17:08
0

you can use useRef to pass specific props without losing reactivity

const imgRef = toRef(props, "img");
const { loding, show } = useLoadImage(imgRef);

Not the answer you're looking for? Browse other questions tagged or ask your own question.