v-bind not unwrapping reactive properties from composable

I have a helper composable here for orchestrating common aria attributes for form fields.
export function useFieldControl(args: UseFieldControlArgs): UseFieldControlReturn {
const fieldId = args.id || useId();
const labelId = `${fieldId}-label`;
const descriptionId = computed(() => (args.description ? `${fieldId}-description` : undefined));
const errorId = computed(() => (args.error ? `${fieldId}-error` : undefined));

const labelProps = computed(() => ({ htmlFor: fieldId, id: labelId }));
const descriptionProps = computed(() => ({ id: descriptionId.value }));
const errorProps = computed(() => ({ id: errorId.value }));

const fieldProps = computed(() => {
const describedby = [descriptionId.value, errorId.value].filter(
(id) => typeof id !== "undefined",
);

return {
"aria-describedby": describedby.length ? describedby.join(" ") : undefined,
"aria-invalid": Boolean(errorId.value) || undefined,
"aria-labelledby": labelId,
id: fieldId,
};
});

return { labelProps, descriptionProps, errorProps, fieldProps };
}
export function useFieldControl(args: UseFieldControlArgs): UseFieldControlReturn {
const fieldId = args.id || useId();
const labelId = `${fieldId}-label`;
const descriptionId = computed(() => (args.description ? `${fieldId}-description` : undefined));
const errorId = computed(() => (args.error ? `${fieldId}-error` : undefined));

const labelProps = computed(() => ({ htmlFor: fieldId, id: labelId }));
const descriptionProps = computed(() => ({ id: descriptionId.value }));
const errorProps = computed(() => ({ id: errorId.value }));

const fieldProps = computed(() => {
const describedby = [descriptionId.value, errorId.value].filter(
(id) => typeof id !== "undefined",
);

return {
"aria-describedby": describedby.length ? describedby.join(" ") : undefined,
"aria-invalid": Boolean(errorId.value) || undefined,
"aria-labelledby": labelId,
id: fieldId,
};
});

return { labelProps, descriptionProps, errorProps, fieldProps };
}
i try to use it in a consuming component without destructuring the returned object
// TextField.vue
const args = useFieldControl(props);
// TextField.vue
const args = useFieldControl(props);
and 'spreading' the props for each respective component via v-bind
<template>
<div class="text-field">
<VFieldLabel v-bind="args.labelProps">{{ label }}</VFieldLabel>
...rest of component
</div>
</template>
<template>
<div class="text-field">
<VFieldLabel v-bind="args.labelProps">{{ label }}</VFieldLabel>
...rest of component
</div>
</template>
however the v-bind only works if i either add the .value to the end of each object or if i destructure the values in the script and reference them that way. am i doing something wrong? i was under the impression v-bind unwraps computed values automagically....
2 Replies
kapa.ai
kapa.ai2d ago
Hi @BinaryArtifex. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave: ---------------------- Your understanding about v-bind unwrapping computed values automatically is correct, but there's a specific caveat when it comes to refs returned from composables. Let me explain the issue and provide a solution. The problem you're encountering is related to how Vue handles ref unwrapping in templates. According to the Vue.js documentation (https://vuejs.org/guide/essentials/reactivity-fundamentals.html#caveat-when-unwrapping-in-templates):
Ref unwrapping in templates only applies if the ref is a top-level property in the template render context.
In your case, args is a top-level property, but args.labelProps is not. This is why you need to add .value or destructure the values. To solve this issue, you have a few options: 1. Destructure the returned object from the composable:
const { labelProps, descriptionProps, errorProps, fieldProps } = useFieldControl(props);
const { labelProps, descriptionProps, errorProps, fieldProps } = useFieldControl(props);
Then use it in the template:
<VFieldLabel v-bind="labelProps">{{ label }}</VFieldLabel>
<VFieldLabel v-bind="labelProps">{{ label }}</VFieldLabel>
2. Use .value in the template:
<VFieldLabel v-bind="args.labelProps.value">{{ label }}</VFieldLabel>
<VFieldLabel v-bind="args.labelProps.value">{{ label }}</VFieldLabel>
3. Create a computed property for each prop:
const labelProps = computed(() => args.labelProps.value);
const descriptionProps = computed(() => args.descriptionProps.value);
const errorProps = computed(() => args.errorProps.value);
const fieldProps = computed(() => args.fieldProps.value);
const labelProps = computed(() => args.labelProps.value);
const descriptionProps = computed(() => args.descriptionProps.value);
const errorProps = computed(() => args.errorProps.value);
const fieldProps = computed(() => args.fieldProps.value);
Then use these in your template:
<VFieldLabel v-bind="labelProps">{{ label }}</VFieldLabel>
<VFieldLabel v-bind="labelProps">{{ label }}</VFieldLabel>
The first option (destructuring) is generally the most straightforward and commonly used approach. This behavior is consistent with how Vue 3 and Nuxt 3 handle reactivity, especially when working with composables. It's a common gotcha that many developers encounter when working with the Composition API. Stack Overflow: Nuxt 3 sometimes I can omit .value when using useState, sometimes I can't, why? (https://stackoverflow.com/questions/78670993) provides a similar example and explanation of this behavior. Remember, when working with composables, it's often best to destructure the returned values to ensure proper reactivity in your templates. ---------------------- Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: ----------------------
kapa.ai
kapa.ai2d ago
<@382140070183960576> Kapa.ai is still learning and improving, please let me know how I did by reacting below.
Want results from more Discord servers?
Add your server