How Do I Share Async API Data across Components in Vue 3? - vue.js

I have a top-level component that gets data from an API at regular intervals. I want to make a single API request and get all the data for my app in one place to reduce the number of requests to the API server. (FYI, my project looks like it's using Typescript but I'm not yet.)
Everything works fine in my top-level component:
//Parent
<script lang="ts">
import { defineComponent, ref, provide, inject, onMounted } from 'vue'
import getData from '#/data.ts'
export default defineComponent({
setup(){
const workspaces = ref([])
onMounted(async () => {
let api = inject('api') //global var from main.ts
let data = await getData(api) //API request inside data.ts
console.log(data.workspaces) //<-- data looks good here
workspaces.value = data.workspaces
//Trying to share workspaces with other components
provide('workspaces', data.workspaces)
})
return {
workspaces
}
}
})
</script>
<template>
{{ workspaces}} <!-- workspaces render fine here -->
</template>
But my child can't use the provide data via inject:
//Child
<script lang="ts">
import { defineComponent, inject, onMounted, ref } from 'vue'
export default defineComponent({
setup(){
let workspaces = ref([])
onMounted(async () => {
workspaces.value = await inject('workspaces') //<-- just a guess; doesn't work
})
return{
workspaces
}
}
})
</script>
<template>
{{ workspaces }} <!-- nothing here -->
</template>
I've made a couple assumptions as to the cause of the problem:
The Child component loads before the parent's async stuff is done, and is therefore empty.
I probably can't use project/inject in async scenarios like this.
So how can I share async data from an API across components in my app? Is my only option to go back to old-school props and pass the data down manually?

provide/inject are misused and subject to race conditions. Composition API is generally supposed to be at used on component initialization (setup, before any await) and not in onMounted. Even if there weren't such restriction, onMounted in parent component runs after the one in child component and can't provide a value at the time when a child is mounted.
The purpose of refs is to provide a reference to a value that can be changed later, so it could be passed by reference and stay reactive, this property isn't currently used.
It should be in parent component:
setup(){
const workspaces = ref([])
provide('workspaces', workspaces)
let api = inject('api')
onMounted(async () => {
let data = await getData(api)
workspaces.value = data.workspaces
})
return { workspaces }
In child component:
setup(){
let workspaces = inject('workspaces')
return { workspaces }

Related

Composition API | Vue route params not being watched

I have pretty much the same example from the docs of watch and vue router for composition API. But console.log is never getting triggered even though the route.params.gameId is correctly displayed in template
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
const store = useStore()
const route = useRoute()
watch(
() => route.params.gameId,
async newId => {
console.log("watch"+newId)
}
)
</script>
<template>
<div>
{{route.params.gameId}}
</div>
</template>
What am I doing wrong? I also tried making watch function non async but it didn't change anything and later on I will need it for api fetching so it should be async.
You should add immediate: true option to your watch :
watch(
() => route.params.gameId,
async newId => {
console.log("watch"+newId)
},
{
immediate: true
}
)
Because the watch doesn't run at the first rendering while the params is changes only one time at the page load, So the watch misses that change.

Why is this composable not reactive?

I have this composable in my app:
// src/composition-api/usePermissions.js
import { ref, readonly } from 'vue'
import { fetchData } from 'src/utils/functions/APIFunctions'
export function usePermissions() {
const permissions = ref([])
const name = ref('')
const fetchCurrentUser = () => {
fetchData('users/me').then(res => {
name.value = `${res.first_name} ${res.last_name}`
permissions.value = res.roles
})
}
return {
name: readonly(name),
permissions: readonly(permissions),
fetchCurrentUser,
}
}
FetchCurrentUser is called in the main layout component.
<template>
<!-- src/layouts/MainLayout.vue -->
<q-layout view="lHh Lpr lFf">
<!-- Redacted for brevity -->
</q-layout>
</template>
<script setup>
import { defineComponent, ref, onMounted } from "vue";
import EssentialLink from "src/components/EssentialLink.vue";
import { usePermissions } from "src/composition-api/usePermissions";
const { fetchCurrentUser } = usePermissions();
/* redacted for brevity */
onMounted(() => {
fetchCurrentUser();
});
</script>
And the state is used in other components, such as this one.
<template>
<!-- src/components/loggedUserLabel.vue -->
<div>{{ name }}</div>
</template>
<script setup>
import { usePermissions } from "src/composition-api/usePermissions.js";
const { name } = usePermissions();
</script>
The fetchCurrentUser() function is used when starting the app or when a new user logs in. I want to use this composable in other components to restrict access to some parts of the app depending on user permissions, and to display the username. However, the name and permissions properties are not reacting to changes. What could be wrong here?
If it matters, I'm using Quasar. I could use Pinia for this but I only need this kind of store-like shared state here, so it seems like overkill to add another library.
At Estus Flask's request, I have created a MCVE - and the same problem keeps happening. Note how in src/layouts/MainLayout.vue the API call is made, but the message in src/components/PokemonLabel.vue does not update, despite receiving a valid response from the Pokeapi.
I have found a similar answered question but it is about a different situation.
Thanks in advance for your help!
name and fetchCurrentUser are supposed to be used in the same component. The state of fetchCurrentUser isn't shared between components, this is by design.
In order to make the state global, it should be created once per component hierarchy, e.g.:
const name = ref('')
export function usePermissions() {
const fetchCurrentUser = ...
return {
name: readonly(name),
fetchCurrentUser,
}
}
This won't work correctly for SSR application like Quasar because there are multiple application instances for different users, but the state is created once and shared between them. In this case the state likely should be created for a hierarchy of components, e.g.:
export function setupPermissions() {
const name = ref('')
const fetchCurrentUser = ...
provide('permissionsStore', {
name: readonly(name),
fetchCurrentUser,
})
}
export function usePermissions() {
return inject('permissionsStore');
}
Where usePermissions is used in child component. And setupPermissions is used in root component, or can be rewritten as a plugin.

Call a function from another component using composition API

Below is a code for a header and a body (different components). How do you call the continue function of the component 2 and pass a parameter when you are inside component 1, using composition API way...
Component 2:
export default {
setup() {
const continue = (parameter1) => {
// do stuff here
}
return {
continue
}
}
}
One way to solve this is to use events for parent-to-child communication, combined with template refs, from which the child method can be directly called.
In ComponentB.vue, emit an event (e.g., named continue-event) that the parent can listen to. We use a button-click to trigger the event for this example:
<!-- ComponentB.vue -->
<script>
export default {
emits: ['continue-event'],
}
</script>
<template>
<h2>Component B</h2>
<button #click="$emit('continue-event', 'hi')">Trigger continue event</button>
</template>
In the parent, use a template ref on ComponentA.vue to get a reference to it in JavaScript, and create a function (e.g., named myCompContinue) to call the child component's continueFn directly.
<!-- Parent.vue -->
<script>
import { ref } from 'vue'
export default {
⋮
setup() {
const myComp = ref()
const myCompContinue = () => myComp.value.continueFn('hello from B')
return {
myComp,
myCompContinue,
}
},
}
</script>
<template>
<ComponentA ref="myComp" />
⋮
</template>
To link the two components in the parent, use the v-on directive (or # shorthand) to set myCompContinue as the event handler for ComponentB.vue's continue-event, emitted in step 1:
<template>
⋮
<ComponentB #continue-event="myCompContinue" />
</template>
demo
Note: Components written with the Options API (as you are using in the question) by default have their methods and props exposed via template refs, but this is not true for components written with <script setup>. In that case, defineExpose would be needed to expose the desired methods.
It seems like composition API makes everything a lot harder to do with basically no or little benefit. I've recently been porting my app to composition API and it required complete re-architecture, loads of new code and complexity. I really don't get it, seems just like a massive waste of time. Does anyone really think this direction is good ?
Here is how I solved it with script setup syntax:
Parent:
<script setup>
import { ref } from 'vue';
const childComponent = ref(null);
const onSave = () => {
childComponent.value.saveThing();
};
</script>
<template>
<div>
<ChildComponent ref="childComponent" />
<SomeOtherComponent
#save-thing="onSave"
/>
</div>
</template>
ChildComponent:
<script setup>
const saveThing = () => {
// do stuff
};
defineExpose({
saveThing,
});
</script>
It doesn't work without defineExpose. Besides that, the only trick is to create a ref on the component in which you are trying to call a function.
In the above code, it doesn't work to do #save-thing="childComponent.saveThing", and it appears the reason is that the ref is null when the component initially mounts.

Is it possible to turn that into '<script setup>' ? I try many ways but missing something maybe

Is it possible to turn that into <script setup> ? I try many ways but missing something maybe. Need help !
<script>
import ProductsAPI from '../api/products.api';
export default {
name: 'products',
components: {
product: 'product',
},
data() {
return {
products: [],
error: '',
};
},
async created() {
this.products = await ProductsAPI.fetchAll();
},
};
</script>
It helps to break the problem down into smaller problems that can be tackled individually...
Component registration
Components are automatically registered upon importing their definition in a <script setup>, so registering Product is trivial:
<script setup>
import Product from '#/components/Product.vue' // locally registered
</script>
Data properties
Reactive data properties are declared as ref (or reactive):
<script setup>
import { ref } from 'vue'
const products = ref([])
const error = ref('')
// to change the value of the `ref`, use its `.value` property
error.value = 'My error message'
products.value = [{ name: 'Product A' }, { name: 'Product B' }]
</script>
Alternatively, you can use the new/experimental Reactivity Transform in <script setup>, which globally defines the reactivity APIs, prefixed with $ (e.g., $ref for ref), and avoids having to unwrap refs via .value:
<script setup>
let products = $ref([])
let error = $ref('')
// assign the values directly (no need for .value)
error = 'My error message'
products = [{ name: 'Product A' }, { name: 'Product B' }]
</script>
created lifecycle hook
The <script setup> block occurs in the same timing as the setup hook, which is also the same timing as the created hook, so you could copy your original hook code there. To use await, you could either wrap the call in an async IIFE:
<script setup>
import ProductAPI from '#/api/products.api'
import { ref } from 'vue'
const products = ref([])
;(async () => {
products.value = await ProductAPI.fetchAll()
})()
</script>
...or create an async function that is called therein:
<script setup>
import ProductAPI from '#/api/products.api'
import { ref } from 'vue'
const products = ref([])
const loadProducts = async () => products.value = await ProductAPI.fetchAll()
loadProducts()
</script>
Component name
There's no equivalent Composition API for the name property, but you can use a <script> block in addition to <script setup> in the same SFC to contain any props that are not supported by the Composition API:
<script setup>
⋮
</script>
<!-- OK to have <script> and <script setup> in same SFC -->
<script>
export default {
name: 'products',
}
</script>
Alternatively, you can specify the component's name via its filename. This feature was added in v3.2.34-beta.1. For example, a component with <script setup> whose filename is MyComponent.vue would have a name of MyComponent.
demo
you should import the component before registering it.
<script>
import ProductsAPI from '../api/products.api';
import ProductComp from './components/Product.vue'
export default {
name: 'products',
components: {
'product': ProductComp
},
data() {
return {
products: [],
error: '',
};
},
async created() {
this.products = await ProductsAPI.fetchAll();
},
};
Using script setup would look something like this.
<script setup>
import ProductsAPI from '../api/products.api';
import Product from './components/Product.vue'
let error = '';
let products = await ProductsAPI.fetchAll();
</script>
The problem is specific to composition API, not specifically script setup. As long as a component is properly written, script can, with few exceptions, be easily transformed to script setup by extracting the content of setup while preserving imports and setup return values.
The lifecycle in composition API has close but not direct counterpart to created hook. And the problem is that created was initially misused. Vue heavily borrows from React and has a similar lifecycle, the latter has strict rules regarding the use of side effects on component creation. All side effects are supposed to be performed later when a component has been already mounted.
Vue doesn't have the same technical limitations as React, but some concerns are also applicable. There are just not enough reasons to do side effects in created, so they belong to mounted. There are little to no advantages to have them in created, while the disadvantages are that initial rendering is blocked by a work that could be done later on component mount, and the side effects that are done too early on component creation may result in problems in SSR due to its semantics, and then there are problems with script setup. Also there is clear semantics for mounted and unmounted hooks, the latter may do a cleanup for side effects that occur in the former.
In both script setup and script with setup function, it can be:
let products = ref([]);
onMounted(async () => {
products.value = await ProductsAPI.fetchAll();
});
TL;DR: It's a good practice to use mounted and not created lifecycle hook for asynchronous side effects. mounted is available in script setup.

How do I update props on a manually mounted vue component?

Question:
Is there any way to update the props of a manually mounted vue component/instance that is created like this? I'm passing in an object called item as the component's data prop.
let ComponentClass = Vue.extend(MyComponent);
let instance = new ComponentClass({
propsData: { data: item }
});
// mount it
instance.$mount();
Why
I have a non vue 3rd party library that renders content on a timeline (vis.js). Because the rest of my app is written in vue I'm attempting to use vue components for the content on the timeline itself.
I've managed to render components on the timeline by creating and mounting them manually in vis.js's template function like so.
template: function(item, element, data) {
// create a class from the component that was passed in with the item
let ComponentClass = Vue.extend(item.component);
// create a new vue instance from that component and pass the item in as a prop
let instance = new ComponentClass({
propsData: { data: item },
parent: vm
});
// mount it
instance.$mount();
return instance.$el;
}
item.component is a vue component that accepts a data prop.
I am able to create and mount the vue component this way, however when item changes I need to update the data prop on the component.
If you define an object outside of Vue and then use it in the data for a Vue instance, it will be made reactive. In the example below, I use dataObj that way. Although I follow the convention of using a data function, it returns a pre-defined object (and would work exactly the same way if I'd used data: dataObj).
After I mount the instance, I update dataObj.data, and you can see that the component updates to reflect the new value.
const ComponentClass = Vue.extend({
template: '<div>Hi {{data}}</div>'
});
const dataObj = {
data: 'something'
}
let instance = new ComponentClass({
data() {
return dataObj;
}
});
// mount it
instance.$mount();
document.getElementById('target').appendChild(instance.$el);
setTimeout(() => {
dataObj.data = 'another thing';
}, 1500);
<script src="https://unpkg.com/vue#latest/dist/vue.js"></script>
<div id="target">
</div>
This has changed in Vue 3, but it's still possible when using the new Application API.
You can achieve this by passing a reactive object to App.provide():
<!-- Counter.vue -->
<script setup>
import { inject } from "vue";
const state = inject("state");
</script>
<template>
<div>Count: {{ state.count }}</div>
</template>
// script.js
import Counter from "./Counter.vue";
let counter;
let counterState;
function create() {
counterState = reactive({ count: 0 });
counter = createApp(Counter);
counter.provide("state", counterState);
counter.mount("#my-element");
}
function increment() {
// This will cause the component to update
counterState.count++;
}
In Vue 2.2+, you can use $props.
In fact, I have the exact same use case as yours, with a Vue project, vis-timeline, and items with manually mounted components.
In my case, assigning something to $props.data triggers watchers and the whole reactivity machine.
EDIT: And, as I should have noticed earlier, it is NOT what you should do. There is an error log in the console, telling that prop shouldn't be mutated directly like this. So, I'll try to find another solution.
Here's how I'm able to pass and update props programmatically:
const ComponentClass = Vue.extend({
template: '<div>Hi {{data}}</div>',
props: {
data: String
}
});
const propsObj = {
data: 'something'
}
const instance = new ComponentClass()
const props = Vue.observable({
...instance._props,
...propsObj
})
instance._props = props
// mount it
instance.$mount();
document.getElementById('target').appendChild(instance.$el);
setTimeout(() => {
props.data = 'another thing'; // or instance.data = ...
}, 1500);
<script src="https://unpkg.com/vue#latest/dist/vue.js"></script>
<div id="target">
</div>