Search code examples
javascriptwebvuejs2architectural-patterns

Vue2 - Plugin Install vs. Component lifecycle + main App, Mixins, Services, Vuex Actions - Organizing code that runs on initial startup


There are times when you need to add a bridge between an external API and your application. There's two pieces to this - retrieving data from an API and writing that data to the store. And sending data to an API based on user action and updating the store on the client. Additionally, sometimes you need this code to run immediately on app startup. What's a clean coding pattern to accomplish this in Vue?

This code (in my case) is global to the app and therefore it doesn't make much sense to add it directly inside a UI component.

The simplest place to add this code would be the primary app mounted callback function.

new Vue({
  render: h => h(App),
}).$mount('#app');

// App.vue
mounted() {
  //run startup code here.
}

But as the app grows - it can get rather cluttered if the App.vue file is the "central" place for this type of functionality.

Choices and Tradeoffs

  • Mixins have collision issues making them not ideal especially if you're adding several to the main parent component.
  • Plugins can be a good place for this sort of thing, but you can run into order of operations issues if the plugin expects something to exist and it doesn't yet.
  • Templateless components can feel like a hack. You are adding a component into the template that doesn't actually render anything beyond an empty <div>. Beyond that, they work.
  • Vuex Actions seem like the ideal place for this code, but the store lacks an internal triggering mechanism so the action still needs to be dispatched elsewhere, and if that's App's mounted callback we get back to the dumping ground concerns.
  • a Service/Utility (essentially abstracting the functions into another file and using ES6 module import/exports) is another way to abstract and organize code, but alone they are ignorant of the Vue instance and all the state and prototypes (other services, i18n, etc.). Passing the Vue instance as an argument also feels a bit hacky, and like there should be a more official "Vue" way.

A combination of a Vuex Action that's triggered within a Plugin's install callback might be the best choice here. Anyone have any better suggestions?


Solution

  • This question requires a two-part answer. The first part of the question is, where should the interesting parts of this code live? Where in the application should the call to the API and subsequent logic to run after the call succeeds/fails go?

    The second part is, which Vue mechanism is ideal for triggering that code to run on initial app startup?

    Let's explore some options.

    App Mounted Lifecycle inline code

    The Vue component that's the starting point for the entire application (commonly named App.vue).

    new Vue({
      render: h => h(App),
    }).$mount('#app');
    
    // App.vue
    mounted() {
      //run startup code here.
    }
    

    Pros: We have access to the vue vm instance and all it's properties, and can read from the DOM since our app has been rendered. The mounted lifecycle function runs automatically at app startup. No other plumbing is needed to trigger a call to the code.

    Cons: Any side-effects will cause the app to immediately re-render on initial startup. Ideally some changes happen prior to first render. App.vue can become a dumping ground for any new features that require some code to run on app startup.

    A Renderless Component

    // App.vue (SFC Pattern)
    <template>
      ...
      <someRenderlessComponent />
    </teamplate>
    
    // someRenderlessComponent.vue
    <template>
      <div><!-- empty because I'm not rendering anything.--></div>
    </template>
    <script>
    mounted() {
      //run startup code here
    }
    </script>
    

    Pros: Same as App.vue, plus logic can be abstracted and composed into seperate files, preventing App.vue from becoming cluttered. Code is easier to read, test and maintain. The mounted lifecycle function runs automatically at app startup. No other plumbing is needed to trigger a call to the code.

    Cons: A pointless DOM element is rendered to the page, adding clutter when debugging with DEV tools and (if done enough times) reducing page performance of CSS and JS. This assumes all this component exists for is to be a bridge between your app's store and some external API.

    A Vue Mixin

    A mixin is like a component partial. You define part of the component, such as local state, computed props, lifecycle methods, etc. and you can mix those pre-defined parts in (hence mixin) with other components.

    Mixin Docs

    Pros: Vue does some auto-merging if both the mixin and the component define the same options, including lifecycle methods. Both lifecycle functions fire, and the mixins lifecycle functions will fire first - this is good for our 'startup scenario'.

    Cons: In Vue2, mixin options aren't automatically namespaced. If you don't take great care, you can end up with unintended collisions where two pieces of data or computed props have the same name. Unlike lifecycle methods, other options are recursively merged and the component's version will override the mixin. This could break functionality defined in the mixin if there are several pieces designed to work together.

    A Vue Plugin

    The install function in the plugin is ran immediately on app startup and has access to the Vue Instance.

    Plugin Docs

    Pros: The plugin is abstracted into its own file keeping App.vue clean. The install function runs automatically once Vue.use() is called, so there's no additional plumbing to write or maintain.

    Cons: We have access to the Vue instance, but we don't have access to anything that hasn't already been added to the Vue instance by previous plugins. Order becomes important. We also won't have access to things like the store, router, i18n or any DOM elements, because the vue app hasn't been instantiated yet. So what can be done here is very limited. In the case of needing to commit a store mutation after the result of an API call, this won't suffice.

    A Vuex Action

    Actions in Vuex are async functions where you can make calls to an API and then commit mutations. They are like a mixture of some business logic and subsequent updates to the store, although they do not update the store's state directly, they trigger mutations to do that.

    Vuex Actions docs

    Pros: The code that is communicating with the API is co-located with the store module that's being mutated as a result. There's fewer lookups required. The Vuex store is hooked into Vue's reactivity, which means that any changes to the state will trigger any downstream components that reference that state to re-render automatically.

    Cons: The flux pattern (which Vuex is based on) expects purity when it comes to the store. Any logic in the store is expected to conclude once the store state has been updated. This means that you'll likely need to add any subsequent logic beyond what happens after the store updates elsewhere. This is a strength in some regards. In this case, it means you'll by definition need to split what's arguably a single slice of business logic code across multiple files, making it arguably more difficult to understand and reason about now and in the future. An action also won't trigger on it's own, you'll still need to determine how to trigger this action on app startup.

    Note: For new Vue projects, the Vue team now officially recommends Pinia over Vuex for state management. In part because it's simpler to use and less opinionated than Vuex.

    A Separate JS/TS File (aka Service)

    This is simply an abstraction of some logic into a separate JS/TS module. This isn't a Vue construct, it's just ES6 modules at play.

    // someService.ts
    export class someService = {
      someFunction() {
        // run startup code here
      }
    };
    
    

    Pros: Keeps all the code related to what you're doing separate and easily testable. It's just plain JavaScript/Typescript, so it's easy for anyone from any background to understand if they know JS/TS. A service can work with any of the above constructs described. It's an unopinionated abstraction.

    Cons: The service doesn't have direct scoped access to the Vue instance. You'd have to pass the Vue instance in as an argument, which feels really clunky to me - especially if you're trying to run commits on the store and expecting reactivity. Being an unopinionated abstraction - it isn't tied in to Vue's lifecycle in any way - it won't automatically run on app startup without additional plumbing. You're still going to need to figure out where to call this service from, be it one of the mounted calls mentioned earlier or a plugins install function. So it may be a part of the solution, but it's not enough on it's own.

    Final Recommendation

    Note: Both of these solutions assume you've set up a Vuex module that has state and mutations for modifying that state. It also assumes you have a UI component that's reacting to (by referencing) that state.

    TL;DR: I think the interesting parts (as described above) should go in a Vuex Action. And a Vue Plugin should trigger that Action on app startup in it's install function. Comments that help explain how these are glued together and why are highly recommended.

    If you prefer to leverage a lifecycle method for the trigger, my second choice would be add the interesting parts to a templateless component and trigger that function using that components own lifecycle method that makes the most sense (created or mounted are the likeliest choices). It has less severe downsides than a mixin, but accomplishes mostly the same thing - abstracting some stateful logic away from the main app.

    A mixin could work if you and everyone else working on the project are diligent about manually namespacing the internals of the mixin. But beware - it's a potential footgun.

    Vue doesn't have the same Service API as Angular. And I don't recommend trying to fight the library by duct-taping this sort of thing on, but some disagree with me. It's not a rip of ES modules, they are super useful in other contexts within a Vue project.

    Whatever you do, don't dump everything into App.vue. You'll thank me later.