Skip to content

Vuex Store

We use Vuex as the state management layer in our application. It serves as a centralized store for all the components, with rules ensuring that the state can only be mutated in a predictable fashion.

Have a quick look through the official documentation if you are unfamiliar with it.

Pending Migration

The official state management library for Vue has changed to Pinia.

Migration of our application to Pinia would be one of our future tasks.

API Data

The bulk of this state at this stage is data retrieved from API calls to the backend server. For most cases we trigger API calls via Vuex actions and store the resulting data in Vuex state.

This has a few advantages:

  • There is a single source of truth for the most "current" data.
  • Any additional logic can be handled in a single place, e.g. refreshing affected data
  • Can easily refresh related data and all connected components will automatically be updated.

However, this does come with a few disadvantages:

  • There is a significant amount of boiler plate (more on this later)
  • Owing to the lack of TypeScript support, making API calls through Vuex loses the ability to infer the types of the payload

ApiStore

API data for a given resource is stored in a simple structure:

js
export interface ApiResult<T> {
  data: T | null;
  previousParameters: Record<string, any>;
  status: {
    pending: boolean;
    failed: boolean;
    success: boolean;
    error: string | null;
  };
}
  • data contains the payload from the backend.
  • previousParameters contains the last parameters that were used to retrieve the aforementioned payload
  • status indicates the status of the call - if it's still loading, if it failed, or if it succeeded

This structure allows us to capture metadata for the API call within a single state rather than having multiple entries.

There a few helpers to handle the creation/storage/querying of this structure. These are exposes as static methods on the ApiStore class:

  • ApiStore.queryThenMutate
  • ApiStore.update
  • ApiStore.toData
  • ApiStore.isLoading

A typical store for a particular resource might look like this:

ts
const state = {
  matter: ApiStore.state<MatterDto>()
}

const getters: GetterTree<typeof state, any> = {
  [Store.getters.GET_MATTER]: (state) => state.matter
}

const mutations: MutationTree<typeof state> = {
  [Store.mutations.SET_MATTER]: (state, { params }) => {
    state.matter = ApiStore.update<MatterDto>(state.matter, params);
  }
}

const actions: ActionTree<typeof state, any> = {
  [Store.actions.GET_MATTER]: ({ commit }, params) => {
    const { id } = params || state.matter.previousParameters;
    return ApiStore.queryThenMutate(
      new MatterServiceProxy().getMatterById(id),
      commit,
      Store.mutations.SET_MATTER,
      params,
      {}
    );
  },
  [Store.actions.CREATE_MATTER]: ({ commit, dispatch, state }, matter) => {
    new MatterServiceProxy()
      .createMatter(matter)
      .then((result) => {
        dispatch(Store.actions.GET_MATTERS);
      })
      .catch((error) => {
        // Handle error
        throw error
      });
  }
}

Reducing Boilerplate - ResourceVuex

Clearly the above can be rather tedious. In an effort to reduce the amount of boilerplate for simple CRUD operations, we have introduced a helper function: ResourceVuex.

Essentially, the following generates the equivalent to the above.

ts
ResourceVuex("matter", // Resource name, this will be the name of the state
  Store.getters.GET_MATTER, // Getter name, this will be the name of the getter
  Store.mutations.SET_MATTER, // Mutation name, this will be the name of the mutation
  { // The "GET" action, this will update the state with the above mutation, which can be retrieved from the getter
    action: Store.actions.GET_MATTER, 
    fn: ({id}) => new MatterServiceProxy().getMatterById(id)
  },
  [ // Any additional actions - create, update, delete. These will not directly affect the state, the state will need to be updated using the refresh call.
    {
      action: Store.actions.CREATE_MATTER,
      fn: (matter) => new MatterServiceProxy().createMatter(matter),
      refresh: [MatterStore.actions.GET_MATTERS] // The refresh call should use the global action name, that is, including the name of the module where applicable
    }
  ]
)

Obviously this does not cover every possible case. In more complex cases, using the typical approach will be necessary.

Refreshing Data

Note that the GET action generated by ResourceVuex will automatically use the last set of parameters if not parameters are provided. This is what happens when using the refresh option for the actions.

Other Helper Functions

ResourceDictVuex

There are cases where storing multiple query results is desirable, e.g. something to the effect of

ts
const state = {
  matters: ApiStore.state<Record<string, MatterDto>>()
}

const getters: GetterTree<typeof state, any> = {
  [Store.getters.GET_MATTER]: (state) => (id: string) => state.matters[id]
}

const mutations: MutationTree<typeof state> = {
  [Store.mutations.SET_MATTER]: (state, { id, params }) => {
    state.matter[id] = ApiStore.update<MatterDto>(state.matter[id], params);
  }
}

const actions: ActionTree<typeof state, any> = {
  [Store.actions.GET_MATTER]: ({ commit }, params) => {
    const { id } = params || state.matter.previousParameters;
    return ApiStore.queryThenMutate(
      new MatterServiceProxy().getMatterById(id),
      commit,
      Store.mutations.SET_MATTER,
      params,
      { id }
    );
  },
}

In this case, you can use ResourceDictVuex. This is similar to ResourceVuex, but requires an additional parameter - the identifying key from the parameters that should be used to store the result in the dictionary:

ts
ResourceDictVuex(
  "id", // The key from the request parameters that should be used to identify the data object, in this case we are using the "id" parameter
  "matter", // Resource name, this will be the name of the state
  Store.getters.GET_MATTER, // Getter name, this will be the name of the getter
  Store.mutations.SET_MATTER, // Mutation name, this will be the name of the mutation
  { // The "GET" action, this will update the state with the above mutation, which can be retrieved from the getter
    action: Store.actions.GET_MATTER, 
    fn: ({id}) => new MatterServiceProxy().getMatterById(id)
  },
  [ // Any additional actions - create, update, delete. These will not directly affect the state, the state will need to be updated using the refresh call.
    {
      action: Store.actions.CREATE_MATTER,
      fn: (matter) => new MatterServiceProxy().createMatter(matter),
      refresh: [MatterStore.actions.GET_MATTERS] // The refresh call should use the global action name, that is, including the name of the module where applicable
    }
  ]
)

Data stored this way can be retrieved using something to the effect of:

ts
const matter = computed(() => store.getters[MatterStore.GET_MATTER](id))

If a refresh option is provided, the value of the identifier is also passed through, e.g. the "id" of the Matter in the above example. This is to allow queries to request updates for a specific entry.

ResourceInfiniteVuex

A feature required is the use of "infinite" lists as opposed to normal numeric paging. This can be achieved by using the ResourceInfiniteVuex store method. This automatically handles appending new results to the data set instead of replacing it.

It assumes that at least the limit and offset parameters are part of the query and simply appends the results if the offset is greater than 0.

This is primarily handled by the infinite-list composable. See the code for an example of its usage.

This can be configured in the same way as ResourceVuex:

ts
ResourceInfiniteVuex(
  "matters",
  Store.getters.GET_MATTERS,
  Store.mutations.SET_MATTERS,
  {
    action: Store.actions.GET_MATTERS,
    fn: ({ statuses, search, limit, offset }) =>
      new MatterServiceProxy().getMatterList(statuses, search, limit, offset)
  }
)

Released by DevOps Team