Converting depending values on the fly in VueJS

Converting user input into a bunch of numbers based on predefined business logic is usually a common task. I came across a problem where the output of the users input was transformed and put back into the input.

The problem with data, which is getting processed by a function twice, is that it leads to inconsistent results. Especially if you feed that data back into the input element, while the input process is in progress.

The issue with a naive apporch

Let's have a look at the core issue of our problem. Our title should be cleaned up (transformed) on the fly. Let's have a look at a bad example.

<input :value="title" @input="cleanupInput">
cleanupInput(e) {
    this.title = someCleanupFunction(e.target.value);
}

Since the user keeps typing while we are processing his input, we override his data with our "clean" version of his input. Thie user experience suffers a lot. The cursor jumps back and forth and some characters are missing.

The Quest

Let's imagine that we want to create a simple calculation helper component to convert prices based on a predifined business logic. We want to convert prices from per km to per ton and per transport depending on the users source of input.

Explore the concept on codesandbox.io

The Concept

The price type and conversion method is driven by the users input choice, we have to handle all of our three combinations differently.

<input :value="perKm" @input="convertByKm">
<input :value="perTransport" @input="convertByTransport">
<input :value="perTon" @input="convertByTon">

We are going to transform all the other values, except the provided one. Since it's the source of truth in that particular moment. Which prevents wild rendering/racing issues with the input itself (like described before).

convertByKm(priceKm) {
    this.perKm = priceKm;
    this.perTransport = priceKm * DISTANCE;
    this.perTon = this.perTransport / PAYLOAD;
}

convertByTransport(priceTransport) {
    this.perKm = priceTransport / DISTANCE;
    this.perTransport = priceTransport;
    this.perTon = priceTransport / PAYLOAD;
}

convertByTon(priceTon) {
    this.perTransport = priceTon * PAYLOAD;
    this.perKm = this.perTransport / DISTANCE;
    this.perTon = priceTon;
}

The Solution

Since our calculation depends on some base values which might change if we calculate different prices eg. retail, purchase, it's probably better to enclose our calculation inside a class.

Our class has to perform calculations, holding the raw input value and cleanup our values for presentation purposes (rounding).

We round the calculated values, except the provided one. In some cases the calculations could lead to some crazy numbers like 13.6666666666666667 and we don't want to show them to the user.

Rounding issues are a sacrifice, we can accept at this point.

— I, will probably regret that decision

The getters are a way to allways get the presentation value.

class Price {
    // distance in meters
    // payload in kilograms
    constructor(base = { distance: 0, payload: 0 }) {
        this.base = base;

        this.perKm = 0;
        this.perTransport = 0;
        this.perTon = 0;
    }

    // Rounding numbers with fixed precision
    preciseFloat(num, precision = 2) {
        return parseFloat(parseFloat(num).toFixed(precision));
    }

    setPerKm(priceKm) {
        this.perKm = priceKm;
        this.perTransport = this.preciseFloat(priceKm * this.base.distance / 1000);
        this.perTon = this.preciseFloat(this.perTransport / this.base.payload * 1000);
    }

    setPerTransport(priceTransport) {
        this.perTransport = priceTransport;
        this.perKm = this.preciseFloat(priceTransport / this.base.distance * 1000);
        this.perTon = this.preciseFloat(priceTransport / this.base.payload * 1000);
    }

    setPerTon(priceTon) {
        this.perTon = priceTon;
        this.perTransport = this.preciseFloat(priceTon * this.base.payload / 1000);
        this.perKm = this.preciseFloat(this.perTransport / this.base.distance * 1000);
    }

    getPerKm() {
        if (!this.perKm) return 0;
        return this.preciseFloat(this.perKm);
    }

    getPerTransport() {
        if (!this.perTransport) return 0;
        return this.preciseFloat(this.perTransport);
    }

    getPerTon() {
        if (!this.perTon) return 0;
        return this.preciseFloat(this.perTon);
    }
}

Our Price class can be used to perform similar operations with a different data base. For example, retail and purchase prices, which have the same base but different values.

export default {
    data() {
        return {
            baseData: { distance: 2000, payload: 5000 },

            retail: null,
            purchase: null,
        }
    },
    created() {
        this.retail = new Price(this.baseData);
        this.purchase = new Price(this.baseData);

        this.retail.setPerTransport(2000);
        this.purchase.setPerTransport(1000);
    }
}

We are going to use the all of our 3 parts of the class for every value.

  • purchase.perKm is the raw input value, or a calculated product of our computation.
  • purchase.setPerKm(...) is the update method, which sets our input value (since we provided the value itself) and recalculates all the other values.
  • purchase.getPerKm() is the presentation value.
<!-- KM -->
<input
    :value="purchase.perKm"
    type="number"
    @input="purchase.setPerKm($event.target.value)"
/>
<div>{{ purchase.getPerKm() }} € / km</div>

<!-- TRANSPORT -->
<input
    :value="purchase.perTransport"
    type="number"
    @input="purchase.setPerTransport($event.target.value)"
/>
<div>{{ purchase.getPerTransport() }} € / Transport</div>

<!-- TON -->
<input
    :value="purchase.perTon"
    type="number"
    @input="purchase.setPerTon($event.target.value)"
>
<div>{{ purchase.getPerTon() }} € / t</div>

Conclusion

Play around with the final concept on codesandbox.io.

This small pattern is a solution to an uncommon problem. If you have any questions or a different way to solve that issue, hit me up on twitter.