Vue Props
It is a farily common scenario in any system that the frontend receives some initial data from a backend, and then modifies and sends it back.
If the UI is more complex, and consist of nested elements, usually the parent component gets the data, and passes down to its children as properties. Once the child modifies it, it has to push back the changes to the parent component responsible for the API communication. Vue actually doesn't make it simple to achive this, with the readonly properties.
Let me summarise the requirements:
- The child component must be able to recieve an initial value
- It also must be able to reflect any changes to this value later
- And finally must be able to change this data, and communicate this changes to its parent
Parent component
Here is a simple example of a parent components, having a reactive counter
property, passed down to it's child:
<template>
<Panel>
<template #header>
<header class="p-5 m-5">
<div class="wrapper"></div>
</header>
</template>
<main class="p-5 flex flex-row gap-5">
<Card class="basis-1/2">
<template #title>Counter: {{ counter }}</template>
<template #content>
<CounterComponent :counter="counter" @update:counter="(event: number) => (counter = event)" />
</template>
<template #footer>
<Button @click="counter++">Increment</Button>
<Button @click="counter--">Decrement</Button>
</template>
</Card>
</main>
</Panel>
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue'
import Card from 'primevue/card'
import Button from 'primevue/Button'
const counter: Ref<number> = ref<number>(10)
</script>
Naive implementation
The naive implementation of the children is to also have a property called counter
:
<template>
<Card>
<template #content>
<InputNumber v-model="props.counter" />
</template>
<template #footer>
<Button @click="props.counter++">Increment</Button>
<Button @click="props.counter--">Decrement</Button>
</template>
</Card>
</template>
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/Button'
import { computed } from 'vue'
const props = defineProps({
counter: {
type: Number,
required: true
}
});
</script>
There is one massive problem with this:
Reactive variable
We can change our implementation that the children also have a reactive counter
variable, and maybe copy out the initial value of the property:
<template>
<Card>
<template #content>
<InputNumber v-model="counter" />
</template>
<template #footer>
<Button @click="counter++">Increment</Button>
<Button @click="counter--">Decrement</Button>
</template>
</Card>
</template>
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/Button'
import { ref } from 'vue'
const props = defineProps({
counter: {
type: Number,
required: true
}
});
const counter = ref(props.counter);
</script>
While this solution eliminates the problem with not being able to update the counter from within the child component, and also receives the initial value from the parent component, it won't react if it gets updated in the parent:
Writeable computed property
A common solution to this is something called writeable computed property, which involves a computed variable, where the getter part returns the property directly, while the setter part emits an event to the parent component, and delegates the responsibility of updating the data.
<template>...</template>
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/Button'
import { ref } from 'vue'
const props = defineProps({
counter: {
type: Number,
required: true
}
});
const emit = defineEmits(['update:counter']);
const counter = computed({
get() {
return props.counter
},
set(value) {
emit('update:counter', value)
}
});
</script>
This solves all our initial requirements, it can receive initial data from the parent, can update the value, and this update is emitted to the parent.
VueUse composable
This is a common solution, as a matter of fact the VueUse library has a composable called useVModel
to simplify this syntax:
<template>...</template>
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/button'
import { useVModel } from '@vueuse/core'
const props = defineProps({
counter: {
type: Number,
required: true
}
});
const emit = defineEmits(['update:counter']);
const counter = useVModel(props, 'counter', emit);
</script>
There is only one big issue with this. This component can not work on it's own, it always relies on it's parent to update the data.
This can be easy to overlook, and might be hard to debug.
Watchers
In general I'm not a huge fan of watchers, but in this case this one seems to be the final solution:
- The children can get an initial data from its parent
- If the data is changed in the parent, it will be reflected in the children
- The children can modify the data internally
- This update can be emitted to the parent - but it is not a requirement for the child component to work
<template>
<Card>
<template #title>Child counter: {{ counter }}</template>
<template #content>
<InputNumber v-model="counter" />
</template>
<template #footer>
<Button @click="counter++">Increment</Button>
<Button @click="counter--">Decrement</Button>
</template>
</Card>
</template>
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Card from 'primevue/card'
import Button from 'primevue/button'
import { ref, watch, type Ref } from 'vue'
const props = defineProps({
counter: {
type: Number,
required: true
}
});
const emit = defineEmits(['update:counter']);
const counter: Ref<number> = ref(props.counter);
watch(() => props.counter, (value: number) => counter.value = value);
watch(() => counter.value, (value: number) => emit('update:counter', value));
</script>