Handling nested data in Vue with Vuex ORM

Davor Tvorić - Feb 24 '20 - - Dev Community

I’ve recently started working on a fairly complex application regarding the structure and the amount of data I get from our API. A lot of the data is meant to be reused and some of them reference the same relationships. This meant that I would, most likely, get tangled up with having some of the data not properly updated.

A while back, a colleague suggested to me to try out Vuex ORM. He reasoned that it should help a lot with nested data and act as a single source of truth. Its job is to map the data you receive from the back-end, after all.
I was convinced, so I’ve read the documentation, tried it out and all I can say is that it made my life a whole lot simpler! In addition to storing the data, I was amazed by how easy it is to get the specific data (and its relationships), format it and filter it by using the query builder. I also realized that a lot of these features would not be properly utilized if you had a simple application in mind. The additional complexity might not be worth it.

I won’t bore you with the basics of Vuex ORM because you can read all about them in the documentation. However, I will show you how I’m currently using it and which features have proved to be really useful.

The whole plugin is really simple to set up. The only additional thing I had to think about is JSON:API. It turned out that it wasn’t so difficult because the community around Vuex ORM was busy with making all sorts of additional features for Vuex ORM. I have used a JSON:API normalizing library that was compatible with Vuex ORM. I have opted out of using their Axios plugin because I needed more control over the data I was receiving. So, in the response interceptor, I added the JSON:API normalizer.

import JsonApiResponseConverter from 'json-api-response-converter';
.
.
.
appAxios.interceptors.response.use(async (response) => {
       if (response.headers['content-type'] && 
           response.headers['content-type'].includes('application/vnd.api+json')) {
           response.data = new JsonApiResponseConverter(response.data).formattedResponse;
       }
   return response;
});

That was pretty much it. Now I could go on and create my models and actually use the library.

After I’ve written a few models, I realized that I was creating a non-orthogonal system. If I’ve wanted to switch some parts of the application in the future, it would prove to be a nearly impossible task. That’s why I’ve decided to separate the concerns in my application and make less of a rigid structure. This is what I came up with and what I’m currently using.

The folder structure

├── src/
│   ├── API/ - contains the files that handle API calls
│   ├── models/ - contains the files that define the ORM models
│   ├── repositories/ - contains the files that act like getters for the ORM

All of this could have been written inside the ORM model, but I found out that the files tend to grow a lot and the code gets a bit messy. You will see my point in the examples.

Example

models/OfferItem.ts

export default class OfferItem extends Model {
    public static entity = 'offerItem';

    // defines all of the fields and relationships on a model
    public static fields() {
        return {
            id: this.attr(null),
            formData: this.attr([]),
            offerItemType: this.string(''),
            price: this.number(''),
            priceDetails: this.attr([]),
            priceDate: this.string(''),
            createdAt: this.string(''),
            updatedAt: this.string(''),
            offer_id: this.attr(null),
            // simple inverse one-to-one relationship
            product_id: this.attr(null),
            product: this.belongsTo(Product, 'product_id'),
            material_id: this.attr(null),
            material: this.belongsTo(ProductCatalogue, 'material_id'),
            offer: this.belongsTo(Offer, 'offer_id'),
        };
    }

    // all of the methods that can be done with the model
    // i.e. fetch all, search, delete, update, etc.
    // we use the API layer here, not in the components
    public static async getById(offerItemId: string) {
        let offerItem;
        try {
            offerItem = await OfferItemAPI.getById(offerItemId);
        } catch (e) {
            return Promise.reject(e);
        }

        this.insertOrUpdate({
            data: offerItem.data,
            insertOrUpdate: ['product', 'offer'],
        });

        return Promise.resolve();
    }

    public static async updateExisting(
        formData: ChecklistFieldEntry[],
        offerItemId: string,
        offerItemType: string) {
        let offerItem;
        try {
            offerItem = await OfferItemAPI.updateExisting(
                offerItemId, 
                formData, 
                offerItemType);
        } catch (e) {
            return Promise.reject(e);
        }

        this.insertOrUpdate({
            data: offerItem.data,
            insertOrUpdate: ['product', 'offer', 'material'],
        });

        return Promise.resolve();
    }
}

api/OfferItemsAPI.ts

import OfferItem from '@/models/OfferItem';

export default class OfferItemAPI {
    // makes the actual call to the back-end
    public static async updateExisting(offerItemId: string, formData: ChecklistFieldEntry[], offerItemType: string) {
        const request = {
            data: {
                type: 'offer_items',
                id: offerItemId,
                attributes: {
                    offerItemType,
                    formData,
                },
            },
        };

        let offerItem;
        try {
            offerItem =
                await ApiController.patch(ApiRoutes.offerItem.updateExisting(offerItemId), request) as AxiosResponse;
        } catch (e) {
            return Promise.reject(e);
        }

        return Promise.resolve(offerItem);
    }

public static async updateExistingMaterial(offerItemId: string, formData: ChecklistFieldEntry[]) {
        const request = {
            .
            .
            .
        };

        let offerItem;
        try {
            offerItem =
                await ApiController.patch(ApiRoutes.offerItem.updateExisting(offerItemId), request) as AxiosResponse;
        } catch (e) {
            return Promise.reject(e);
        }

        return Promise.resolve(offerItem);
    }
}

repositories/OfferItemsRepository.ts

import OfferItem from '@/models/OfferItem';

// using the query builder, we can easily get the specific data
// we need in our components
export default class OfferItemRepository {
    public static getById(offerItemId: string) {
        return OfferItem.query().whereId(offerItemId).withAll().first();
    }
}

Even with a smaller example, you can see that the complexity would only grow with having everything in just one file.

The next step of this is to use it properly and keep the layers separate. The API layer is never used inside of a component, the component can only communicate with the model and the repository.

Concerns

Even though this has been a great help, I have run into some issues that have been bugging me.

Model interfaces

When you define a model and want to use the properties you set it, Typescript will argue that the properties you are using do not exist. I'm assuming this has to do with the facts they are nested in the "fields" property. Not a major issue, but you would have to write an additional interface to escape the errors.

json-api-response-converter

The library suggested by Vuex ORM has some issues when handling cyclical JSON. I have chosen to use jsona instead. The switch was relatively simple because of the way the libraries handle deserialization.

Conclusion

Even though there are some smaller nuances with the library I've run into, I would still urge you to try it out on your complex Vue projects. It's a great benefit to not worry about the data you have and just focus on the business logic of your application.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player