srchulo
srchulo

Reputation: 5203

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.

Upvotes: 0

Views: 2032

Answers (1)

b0nyb0y
b0nyb0y

Reputation: 1446

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: "[email protected]"
        };
        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:

  1. 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.

  2. 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.

Upvotes: 2

Related Questions