How to deduplicate data fetches in children routes - vue.js

Imagine the following routes. I'm using Vue and vue-router syntax right now, but I figure the question applies to other SPA frameworks as well.
{
path: 'user/:id', component: require('User.vue'),
children: [
{ path: 'edit', component: require('UserEdit.vue'), }
]
}
In User.vue, the user object is fetched using the route id parameter upon component creation:
data() {
return { user: null }
},
created() {
this.user = fetchUser(this.$route.params.id)
}
In UserEdit.vue, a user is also fetched, and in 85% of the cases this will be the user that was also fetched in User.vue:
data() {
return { user: null }
},
created() {
this.user = fetchUser(this.$route.params.id)
}
Question: if we would navigate from User.vue to UserEdit.vue, it is apparent that (most probably) the same user object will be fetched again. How can this kind of code duplication be avoided? How should I pass the previously fetched data down to a child route?
I guess I should somewhere check if the route parameters remain equal, because if they aren't we're editing another user and the User data should be fetched anyway...
Time for a state management store (like vuex)? If so, when the app navigates away from user pages, should the user store be cleared, or do you keep the last fetched user always in memory?
I'm having a hard time to come up with something DRY.
Looking forward to your advice and some hands-on code examples.

Use vuex for state management. For example, setting something like lastUser and userData which could be accessed from any component. fetchUser would then be an action in the store:
Store
state: {
lastUser: '',
userData: null
},
actions: {
fetchUser({ state }, user) {
if (state.userData && user == state.lastUser) {
return state.userData;
} else {
// Api call, set userData and lastUser, return userData
}
}
}
User
async created() {
this.user = await this.$store.dispatch('fetchUser', this.$route.params.id);
}
UserEdit
async created() {
this.user = await this.$store.dispatch('fetchUser', this.$route.params.id);
}

Related

Vue component check if a vuex store state property is already filled with data

here is my setup. The state "categories" in the state is fetched async from a json endpoint.
In the component I want to work with this data, but if I reload the page the categories are always empty.
methods: {
onSubmit() {
console.log(this.filter);
},
formCats(items) {
console.log(items);
// const arr = flatten(data);
// console.log(arr);
}
},
created() {
const data = this.categories;
this.formCats(data);
},
computed: {
...mapState(['categories'])
}
I also tried async created() with await this.categories. Also not working as expected! Would be great if someone could help me with this. Thanks!
This is happening because the async fetch doesn't finish until after the component is already loaded. There are multiple ways to handle this, here's one. Remove created and turn formCats method into a computed.
computed: {
...mapState(['categories']),
formCats() {
let formCats = [];
if (this.categories && this.categories.length) {
formCats = flatten(this.categories);
}
return formCats;
}
}
formCats will be an empty array at first, and then it will immediately become your formatted categories when this.categories is finished fetching.

How to detect the user comes back to a page rather than starts browsing it?

I've got a grid component that I use in many routes in my app. I'd like to persist its state (ie. paging, search param) and restore it when the user comes back to the grid (ie. from editing a row). On the other hand, when the user starts a new flow (ie. by clicking a link) then the page is set to zero and web service is called with the default param.
How can I recognise the user does come back rather then starts a new flow?
When I was researching the problem I've come across the following solutions.
Unfortunatelly they didn't serve me
1/ using router scroll behaviour
scrollBehavior(to, from, savedPosition) {
to.meta.comeBack = savedPosition !== null;
}
It does tell me if the user comes back. Unfortunately the scroll behaviour runs after grid's created and mounted hooks are called. This way I have no place to put my code to restore the state.
2/ using url param
The grid's route would have an optional param. When the param is null then the code would know it's a new flow and set a new one using $router.replace routine. Then the user would go to editing, come back and the code would know they come back because the route param != null. The problem is that calling $router.replace re-creates the component (ie. calling hooks etc.). Additionally the optional param mixes up and confuses vue-router with other optional params in the route.
HISTORY COMPONENT
// component ...
// can error and only serves the purpose of an idea
data() {
return {
history: []
}
},
watch: {
fullRoute: function(){
this.history.push(this.fullRoute);
this.$emit('visited', this.visited);
}
},
computed: {
fullRoute: function(){
return this.$route.fullPath
},
visited: function() {
return this.history.slice(-1).includes(this.fullRoute)
}
}
the data way
save the information in the browser
// component ...
// can error and only serves the purpose of an idea
computed: {
gridData: {
get: function() {
return JSON.parse(local.storage.gridData)
},
set: function(dataObj){
local.storage.gridData = JSON.stringify(dataObj)
}
}
}
//...
use statemanagement
// component ...
// can error and only serves the purpose of an idea
computed: {
gridData: {
get: function() {
return this.$store.state.gridData || {}
},
set: function(dataObj){
this.$store.dispatch("saveGrid", gridData)
}
}
}
//...
use globals
// component ...
// can error and only serves the purpose of an idea
computed: {
gridData: {
get: function() {
return window.gridData || {}
},
set: function(dataObj){
window.gridData = dataObj
}
}
}

vuex getter does not update on another component depending on timing

My app uses
axios to fetch user information from a backend server
vuex to store users
vue-router to navigate on each user's page
In App.vue, the fetch is dispatched
export default {
name: 'app',
components: {
nav00
},
beforeCreate() {
this.$store.dispatch('fetchUsers')
}
}
In store.js, the users is an object with pk (primary key) to user information.
export default new Vuex.Store({
state: {
users: {},
},
getters: {
userCount: state => {
return Object.keys(state.users).length
}
},
mutations: {
SET_USERS(state, users) {
// users should be backend response
console.log(users.length)
users.forEach(u => state.users[u.pk] = u)
actions: {
fetchUsers({commit}) {
Backend.getUsers()
.then(response => {
commit('SET_USERS', response.data)
})
.catch(error => {
console.log("Cannot fetch users: ", error.response)
})
})
Here Backend.getUsers() is an axios call.
In another component which map to /about in the vue-router, it simply displays userCount via the getter.
Now the behavior of the app depends on timing. If I visit / first and wait 2-3 seconds, then go to /about, the userCount is displayed correctly. However, if I visit /about directly, or visit / first and quickly navigate to /about, the userCount is 0. But in the console, it still shows the correct user count (from the log in SET_USERS).
What did I miss here? Shouldn't the store getter see the update in users and render the HTML display again?
Since it's an object Vue can't detect the changes of the properties and it's even less reactive when it comes to computed properties.
Copied from https://vuex.vuejs.org/guide/mutations.html:
When adding new properties to an Object, you should either:
Use Vue.set(obj, 'newProp', 123), or
Replace that Object with a fresh one. For example, using the object spread syntax we can write it like this:
state.obj = { ...state.obj, newProp: 123 }

Basic Vue store with with dependent API calls throughout the app

I tried asking this question in the Vue Forums with no response, so I am going to try repeating it here:
I have an app where clients login and can manage multiple accounts (websites). In the header of the app, there’s a dropdown where the user can select the active account, and this will affect all of the components in the app that display any account-specific information.
Because this account info is needed in components throughout the app, I tried to follow the store example shown here (Vuex seemed like overkill in my situation):
https://v2.vuejs.org/v2/guide/state-management.html
In my src/main.js, I define this:
Vue.prototype.$accountStore = {
accounts: [],
selectedAccountId: null,
selectedAccountDomain: null
}
And this is my component to load/change the accounts:
<template>
<div v-if="hasMoreThanOneAccount">
<select v-model="accountStore.selectedAccountId" v-on:change="updateSelectedAccountDomain">
<option v-for="account in accountStore.accounts" v-bind:value="account.id" :key="account.id">
{{ account.domain }}
</option>
</select>
</div>
</template>
<script>
export default {
name: 'AccountSelector',
data: function () {
return {
accountStore: this.$accountStore,
apiInstance: new this.$api.AccountsApi()
}
},
methods: {
updateSelectedAccountDomain: function () {
this.accountStore.selectedAccountDomain = this.findSelectedAccountDomain()
},
findSelectedAccountDomain: function () {
for (var i = 0; i < this.accountStore.accounts.length; i++) {
var account = this.accountStore.accounts[i]
if (account.id === this.accountStore.selectedAccountId) {
return account.domain
}
}
return 'invalid account id'
},
loadAccounts: function () {
this.apiInstance.getAccounts(this.callbackWrapper(this.accountsLoaded))
},
accountsLoaded: function (error, data, response) {
if (error) {
console.error(error)
} else {
this.accountStore.accounts = data
this.accountStore.selectedAccountId = this.accountStore.accounts[0].id
this.updateSelectedAccountDomain()
}
}
},
computed: {
hasMoreThanOneAccount: function () {
return this.accountStore.accounts.length > 1
}
},
mounted: function () {
this.loadAccounts()
}
}
</script>
<style scoped>
</style>
To me this doesn’t seem like the best way to do it, but I’m really not sure what the better way is. One problem is that after the callback, I set the accounts, then the selectedAccountId, then the selectedAccountDomain manually. I feel like selectedAccountId and selectedDomainId should be computed properties, but I’m not sure how to do this when the store is not a Vue component.
The other issue I have is that until the selectedAccountId is loaded for the first time, I can’t make any API calls in any other components because the API calls need to know the account ID. However, I’m not sure what the best way is to listen for this change and then make API calls, both the first time and when it is updated later.
At the moment, you seem to use store to simply hold values. But the real power of the Flux/Store pattern is actually realized when you centralize logic within the store as well. If you sprinkle store-related logic across components throughout the app, eventually it will become harder and harder to maintain because such logic cannot be reused and you have to traverse the component tree to reach the logic when fixing bugs.
If I were you, I will create a store by
Defining 'primary data', then
Defining 'derived data' that can be derived from primary data, and lastly,
Defining 'methods' you can use to interact with such data.
IMO, the 'primary data' are user, accounts, and selectedAccount. And the 'derived data' are isLoggedIn, isSelectedAccountAvailable, and hasMoreThanOneAccount. As a Vue component, you can define it like this:
import Vue from "vue";
export default new Vue({
data() {
return {
user: null,
accounts: [],
selectedAccount: null
};
},
computed: {
isLoggedIn() {
return this.user !== null;
},
isSelectedAccountAvailable() {
return this.selectedAccount !== null;
},
hasMoreThanOneAccount() {
return this.accounts.length > 0;
}
},
methods: {
login(username, password) {
console.log("performing login");
if (username === "johnsmith" && password === "password") {
console.log("committing user object to store and load associated accounts");
this.user = {
name: "John Smith",
username: "johnsmith",
email: "john.smith#somewhere.com"
};
this.loadAccounts(username);
}
},
loadAccounts(username) {
console.log("load associated accounts from backend");
if (username === "johnsmith") {
// in real code, you can perform check the size of array here
// if it's the size of one, you can set selectedAccount here
// this.selectedAccount = array[0];
console.log("committing accounts to store");
this.accounts = [
{
id: "001234",
domain: "domain001234"
},
{
id: "001235",
domain: "domain001235"
}
];
}
},
setSelectedAccount(account) {
this.selectedAccount = account;
}
}
});
Then, you can easily import this store in any Vue component, and start referencing values, or call methods, from this store.
For example, suppose you are creating a Login.vue component, and that component should redirect when user object becomes available within a store, you can achieve this by doing the following:
<template>
<div>
<input type="text" v-model="username"><br/>
<input type="password" v-model="password"><br/>
<button #click="submit">Log-in</button>
</div>
</template>
<script>
import store from '../basic-store';
export default {
data() {
return {
username: 'johnsmith',
password: 'password'
};
},
computed: {
isLoggedIn() {
return store.isLoggedIn;
},
},
watch: {
isLoggedIn(newVal) {
if (newVal) { // if computed value from store evaluates to 'true'
console.log("moving on to Home after successful login.");
this.$router.push({ name: "home" });
}
}
},
methods: {
submit() {
store.login(this.username, this.password);
}
}
};
</script>
In addition, with isSelectedAccountAvailable we compute, we can easily disable/enable button on the screen, to prevent user from making API calls until an account is selected:
<button :disabled="!isSelectedAccountAvailable" #click="performAction()">make api call</button>
If you want to see the whole project, you can access it from this runnable codesandbox. Pay attention at how basic-store.js is defined and used in Login.vue and Home.vue. And, if you'd like, you can also see how store is defined in vuex by taking a peek at store.js.
Good luck!
Updated:
About how you should organize dependent/related API calls, the answer is actually right in front of you. If you take a closer look at the store, you'll notice that my login() method subsequently calls this.loadAccounts(username) once the login succeeds. So, basically, you have all the flexibility to chain/nested API calls in store's methods to accommodate your business rules. The watch() is there simply because the UI needs to perform navigation based on change(s) made in the store. For most simple data changes, computed properties will suffice.
Further, from how I designed it, the reason watch() is used in <Login> component is twofold:
Separation of concerns: for me who has been working on server-side code for years, I'd like my view-related code to be cleanly separated from model. By restricting navigation logic inside a component, my model in a store doesn't need to know/care about navigation at all.
However, even if I don't separate concerns, it will still be pretty hard to import vue-router into my store. This is because my router.js already imports basic-store.js to perform navigation guard preventing unauthenticated users from accessing <Home> component:
router.beforeEach((to, from, next) => {
if (!store.isLoggedIn && to.name !== "login") {
console.log(`redirect to 'login' instead of '${to.name}'.`);
next({ name: "login" });
} else {
console.log(`proceed to '${to.name}' normally.`);
next();
}
});
And, because javascript doesn't support cyclic dependency yet (e.g., router imports store, and store imports router), to keep my code acyclic, my store can't perform route navigations.

How do I update the Apollo data store/cache from a mutation query, the update option doesn't seem to trigger

I have a higher order component in my react native application that retrieves a Profile. When I call an "add follower" mutation, I want it to update the Profile to reflect the new follower in it's followers collection. How do I trigger the update to the store manually. I could refetch the entire profile object but would prefer to just do the insertion client-side without a network refetch. Currently, when I trigger the mutation, the Profile doesn't reflect the change in the screen.
It looks like I should be using the update option but it doesn't seem to work for me with my named mutations. http://dev.apollodata.com/react/api-mutations.html#graphql-mutation-options-update
const getUserQuery = gql`
query getUserQuery($userId:ID!) {
User(id:$userId) {
id
username
headline
photo
followers {
id
username
thumbnail
}
}
}
`;
...
const followUserMutation = gql`
mutation followUser($followingUserId: ID!, $followersUserId: ID!) {
addToUserFollowing(followingUserId: $followingUserId, followersUserId: $followersUserId) {
followersUser {
id
username
thumbnail
}
}
}`;
...
#graphql(getUserQuery)
#graphql(followUserMutation, { name: 'follow' })
#graphql(unfollowUserMutation, { name: 'unfollow' })
export default class MyProfileScreen extends Component Profile
...
this.props.follow({
variables,
update: (store, { data: { followersUser } }) => {
//this update never seems to get called
console.log('this never triggers here');
const newData = store.readQuery({ getUserQuery });
newData.followers.push(followersUser);
store.writeQuery({ getUserQuery, newData });
},
});
EDIT: Just realised that you need to add the update to the graphql definition of the mutation.
EDIT 2: #MonkeyBonkey found out that you have to add the variables in the read query function
#graphql(getUserQuery)
#graphql(followUserMutation, {
name: 'follow',
options: {
update: (store, { data: { followersUser } }) => {
console.log('this never triggers here');
const newData = store.readQuery({query:getUserQuery, variables});
newData.followers.push(followersUser);
store.writeQuery({ getUserQuery, newData });
}
},
});
#graphql(unfollowUserMutation, {
name: 'unfollow'
})
export default class MyProfileScreen extends Component Profile
...
this.props.follow({
variables: { .... },
);
I suggest you update the store using the updateQueries functionality link.
See for example this post
You could use compose to add the mutation to the component. Inside the mutation you do not need to call client.mutate. You just call the mutation on the follow user click.
It might also be possible to let apollo handle the update for you. If you change the mutation response a little bit that you add the following users to the followed user and add the dataIdFromObject functionality. link