Declaratively specify the data that would cause actions to need refreshing, and the components that rely on the results of actions.
Switch branches/tags
Nothing to show
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
demo
imgs
src
test
.babelrc
.eslintrc.js
.gitignore
.travis.yml
README.md
package.json
webpack.config.js
yarn.lock

README.md

vue-hagrid

Build Status

Hagrid is responsible for:

  • keeping track of vuex actions, and the conditions when they might need to be dispatched again.
  • keeping track of components that care about those actions. An action will only be dispatched if there's a component mounted that relies on that action.
  • Dispatching actions when the associated getter changes, as long as there's a view who cares about that endpoint.

“You think it — wise — to trust Hagrid with something as important as this?"

"I would trust Hagrid with my life."

Quick Start

Install with npm install --save vue-hagrid.

Choose which actions need to be managed with Hagrid. These usually are actions that don't "belong" to any particular component, but rather to multiple components. Add a hagridResources key to your store, which should be a map from action name to getter name. The getter contains any data that the action wishes to respond to.

  ...
  hagridResources: {
    // projectsPayload is a getter containing the data `fetchProjects` needs
    // in order to perform a fetch. It can be empty.
    fetchProjects: 'projectsPayload',
  },
  ...

In each component that relies on those actions, add a hagridActions key so that Hagrid will know when subscribers are mounted on the page.

   ...
   hagridActions: 'fetchProjects', // or ['fetchProjects', 'anotherAction', ...]
   ...

Import and 'use' the Hagrid plugin:

const Hagrid = require('vue-hagrid');

Vue.use(Hagrid);

The Problem

Much of the time, it's obvious where an action should be dispatched. For <submit @click="login" />, or anything dispatched in response to a user's action, it's easy to say "This component is responsible". For other actions, it's less clear. Consider some data that is needed by several components, and there might be more than one mounted at any moment. There might be none!

A solution might be to dispatch the action in the mounted hook of each component using the data, but to make the action smart enough not to re-fetch data that's already present. This is pretty good, but I'm not sold.

Sometimes the data will need to be refreshed in response to other changes in the store. In an app I'm working on right now a user can belong to multiple organizations. There is a projects/fetch action that should be refreshed when the selected org changes.

Two solutions present:

  1. The action that changes the selected org tells projects/fetch about it.
  2. Each component that cares about the data in projects/fetch must now also listen for changes to the selected org, and dispatch projects/fetch when one occurs.

Solution 1. orgs/select dispatches projects/fetch

Add dispatch('projects/fetch, null, { root: true }) to orgs/select. The project fetch action must now keep track of the orgId from when it last fetched, and compare it with the current value to decide whether to fetch again. Remember, it can't just fetch every time, because it's used by multiple components.

So now, in addition to the logic for what it means to actually grab the data we've given the action another responsibility. The action must track the value of orgId (and whatever else it needs to use to make a request), so that it can tell "Have I been dispatched because another component mounted on the screen (and so my data is still up-to-date), or because some of my dependencies changed (and so I need to re-fetch)?".

But wait. orgs/select will always dispatch projects/fetch. What if there are no components that care about that data right now? We're fetching without regard for if anyone is listening.

Besides the issue of fetching when no one cares about the results is a problem of organization. Imagining that an action could have several data dependencies like this, we're spreading the knowledge of those dependencies to where the dependency changes, rather than where the action is defined. That seems backwards to me.

Solution 2.

For each component, we could listen for changes to each of the action's data dependencies. This gets repetitive in a hurry, since it grows with both the number of components that care about the data, as well as the number of dependencies the action has.

The Solution

We want to move towards declarative code from two sides. On one side, actions should declare what data would cause them to re-render. On the other side, components should declare what actions produce data that they care about. We can manage all the wiring with vue-hagrid.

In the store

vue-hagrid will look for a hagridResources key in each module of your store (and the root). If provided, hagridResources should be an object with the names of actions for keys, and getters for values.

export default {
  namespaced: true,
  actions,
  mutations,
  getters,
  hagridResources: {
    'fetchProjects': 'projectsPayload',
  },
}

The getter represents the data-dependencies of the action. As long as there is a component listening, hagrid will watch the value of the getter and dispatch the action on any change. The value of the getter will be passed as the payload for the action.

In the component

export default {
  name: 'ProjectsList',
  hagridActions: ['fetchProjects', 'checkLogin'],
}

In each component, vue-hagrid will check for a key, hagridActions. If it is provided, it should be a list of actions that the component cares about. vue-hagrid will not trigger any events or communicate to the component directly -- information will flow from the store like usual.

Waiting on actions

Sometimes, you may wish to know in the component when an action has completed. You can access the results (promises) of any action hagrid has dispatched via component.hagridPromise(actionName).

hagridActions: ['fetchProjects'],
async mounted() {
  await this.hagridPromise('fetchProjects');
  // at this point, you can be confident that projects have been fetched.
  const toSelect = this.$route.query.projectId || this.projects[0].id;
  this.selectProject(this.projects.find(p => p.id === toSelect));
},

Events

Hagrid can also trigger actions in response to events. A motivating example of this would be logging out. When the LogOut action occurs, you likely need to clear the data in the rest of your modules.

One way to accomplish this would be to just dispatch a bunch of actions from LogOut: dispatch('Projects/clear'); dispatch('Issues/clear'); .... This is undesirable because the Login module must now maintain a list of which actions to call when a logout occurs. Better would be if each module could opt-in to having an action run when LogOut occurs.

Events would be a classic way to handle this. Where modules are defined, the store doesn't yet exist. Trying to import it would create a circular dependency, so it's awkward to trigger a store.dispatch('MyAction') in response to an event.

Hagrid provides an event bus and a way to specify actions that should be triggered when events occur. The main benefit here is in co-locating the event listener with the action definition, which would be awkward otherwise because of the circular import issue.

Listening for an event

export default {
  namespaced: true,
  actions,
  mutations,
  getters,
  hagridOn: {
    logout: 'clear', // The local 'clear' action will be called when logout is emitted.
  },
}

Emit an event from an action

const Login = {
  // ...
  actions: {
    logOut({ commit }) {
      eraseCookie();
      commit('LOGOUT');

      this.hagrid.emit('logout'); // trigger listeners
    },
  },
}

Emit an event from anywhere you have the store

store.hagridEmit('resize');

Gotchas

"this.hagrid is undefined" "Cannot read property 'emit' of undefined". This can occur when your actions are arrow functions. Change them to regular functions so that their this can be bound to the store. Alternatively, you can import your hagrid instance and use it directly.

logout: ({ commit }) => {
  this.hagrid.emit('logout'); // 💥 crash!
  // ...
}

Change to regular function:

logout({ commit }) {
  this.hagrid.emit('logout'); // 👌 nice.
  // ...
}

Alternatively, import hagrid from wherever you've instantiated it:

import hagrid from '@/hagrid';

logout: ({ commit }) => {
  hagrid.emit('logout'); // 👌 also good.
  // ...
}

Hagrid will only dispatch actions when the getter payload is truthy. That way, hagridPromise('fetchProjects') will not resolve with an early (eg, pre-logged-in) run of the action. This can cause problems if false or null is a value you return from your getter when you want to still run the action. Workaround: just wrap it in an object.

For Example

Check out the /demo directory to see how it's used. The app is running at https://brianschiller.com/vue-hagrid.

Use Cases

Filtering and pagination

This is the use case for which hagrid was born. Seriously. It was in Backbone.js, but this was where I first encountered the need for this kind of problem:

When any of these things change, refetch. But don't refetch 3 times just because 3 components care about the result.

Suppose you have a filter component. Any time a filter choice is altered, a new request should be sent out to find matching product. Simple enough, right? The filter component should just trigger a vuex action and pass along the parameters.

But! Suppose you also have pagination, or sorting controls. They might be far enough away in the UI that it would be awkward or difficult to have a single component responsible for both of them.

Now there are two or three components that need to kick off the same action in response to a change, and each of them is only aware of a piece of the state necessary to send out the request.

Hagrid to the rescue:

// modules/Filters.js
export default {
  state: {
    department: null,
    colors: 'ALL',
    materials: 'ALL',
    min_price_dollars: 0,
    max_price_dollars: 200,
    // etc...
  },
  // mutations, actions, getters, etc ...
};

// modules/Sorting.js
export default {
  state: {
    order: 'popularity',
    direction: 'ascending',
  },
  // mutations, actions, getters, etc ...
};

// modules/Pagination.js
export default {
  state: {
    num_per_page: 20,
    current_page: 1,
  },
  // mutations, actions, getters, etc ...
};

// modules/Products.js
export default {
  state: {
    data: [],
    status: 'unfetched',
  },
  getters: {
    fetchProductOptions(state, getters, rootState) {
      // return a falsy value here if we're not yet
      // ready to fetch. The action will not be triggered.
      if (!rootState.Filters.department)
      return {
        filters: rootState.Filters,
        pagination: rootState.Pagination,
        sorting: rootState.Sorting,
      };
    },
  },
  actions: {
    fetchProducts(context, { filters, pagination, sorting }) {
      // the value returned by the getter, if truthy, is passed
      // as the parameter to the action.
      // if the value returned by the getter is falsy, the action
      // is not dispatched.

      // ... actually call the api
    },
  },

  hagridResources: {
    fetchProducts: 'fetchProductOptions',
  },
};

// components/ProductList.vue
export default {
  name: 'ProductList',
  // hagrid will not dispatch actions if no component currently
  // mounted cares about the action. Components must subscribe
  // using `hagridActions` as below.
  // If two components care about the result, the action will
  // still be dispatched only once.
  hagridActions: ['fetchProducts'],
};

Cascading fetches

First, the user must login. Then, they must choose a project. Then, they can see what reports exist.

One option would be to have the login action dispatch the fetchProjects on success, then have fetchProjects dispatch fetchReports on its success. This unpleasant coupling is avoidable with hagrid.

// modules/Projects.js
export default {
  getters: {
    readyToFetch(state, getters, rootState, rootGetters) {
      // remember, actions are only triggered if
      // the getter returns a truthy value.
      return rootGetters.isLoggedIn;
    },
  },
  actions: {
    fetchProjects(context) {
      fetch('/api/projects')
      // ...
    },
    chooseProject({ commit }, project) {
      commit('CHOOSE_PROJECT', project);
    },
  },
  hagridResources: {
    fetchProjects: 'readyToFetch',
  },
};

// modules/Reports.js
export default {
  getters: {
    readyToFetch(state, getters, rootState, rootGetters) {
      // only fetch projects if we're both logged in,
      // AND a project has been selected.
      return rootGetters.isLoggedIn && rootGetters['Projects/selected'];
    },
  },
  actions: {
    fetchReports(context, project) {
      fetch(`/api/reports?project_id=${project.id}`)
      // ...
    },
  },
  hagridResources: {
    fetchReports: 'readyToFetch',
  },
};

Clear state on logout

(todo)