If both fields in a Vue unit converter are editable, how do you stop a calculated result from overwriting the user's input? I use explicit conversion directions and synchronous watchers so that each numeric edit updates the opposite field without bouncing back.

A Vue length converter gave me a compact example. Each side can update the other, both results are rounded, and a swap button changes units and values together. The arithmetic is routine; the interesting part is stopping intermediate state from becoming a new user instruction.

One Conversion Function, Two Directions

The component imports a shared length-unit table and conversion helper. Each unit has a scale relative to meters, so a conversion goes from the source unit to meters and then from meters to the destination unit.

That keeps the component out of the business of maintaining a separate formula for every possible pair. With eight units, separate directional formulas would create many opportunities for a mismatched constant. A shared base-unit representation needs one scale per unit instead.

The component aliases the shared conversion and rounding helpers, then uses them in two mirror-image synchronization functions:

function syncFromLeft() { if (isSyncing) return; isSyncing = true; toValue.value = roundResult(convert(fromValue.value, fromUnit.value, toUnit.value)); isSyncing = false; } function syncFromRight() { if (isSyncing) return; isSyncing = true; fromValue.value = roundResult(convert(toValue.value, toUnit.value, fromUnit.value)); isSyncing = false; } 

The direction is explicit. Editing the left side means the right side is derived; editing the right side means the left side is derived. The implementation does not average the fields or try to reconcile both as independent measurements.

There is an important boundary to that rule: changing either unit dropdown always recalculates from the left. The component does not store a "last edited side." After entering 1 inch on the right, the left reads 2.54 centimeters. Changing the right unit to feet keeps that left value and produces 0.083333 feet; it does not reinterpret the right-hand 1 as one foot.

The isSyncing variable is not rendered state. It is an imperative guard around a short synchronous operation. Making it a ref would not help explain the calculation, because the template has no reason to react to it.

The Guard Depends on Watcher Timing

Here are the value watchers:

watch(fromValue, syncFromLeft, { flush: "sync" }); watch(toValue, syncFromRight, { flush: "sync" }); 

The flush mode is part of the design, not an incidental performance setting.

When syncFromLeft writes toValue, the synchronous watcher for the right side runs while isSyncing is still true. Its guard returns immediately. The original left-side value remains the authoritative input.

Without that coordination, a rounded result could be converted back and overwrite the user's original value. Vue may stop some repeated assignments when the primitive value is unchanged, so this is not a claim that every naive converter must loop forever. The more subtle problem is that derived output becomes a new input, potentially losing precision.

For a concrete trace, entering 1 centimeter produces 0.393701 inches after rounding. Converting that displayed result back produces 1.000001 centimeters. The right-side watcher must return before performing that second conversion; otherwise the original 1 is no longer preserved. This is worth testing even when the form appears to settle without an infinite loop.

Using default queued watchers with the same short-lived boolean would change the reasoning. A callback that runs after the original function releases the guard no longer sees the protected state. If you change watcher scheduling, you need to reconsider the guard rather than assuming it still protects the same operation.

Synchronous watchers are not batched, so this is a deliberate trade-off for two small calculations, not a blanket recommendation for large reactive collections.

The converter is synchronous, with no request or awaited task inside either helper. I would not copy this pattern unchanged into an asynchronous address lookup or pricing form. A boolean released before an asynchronous response arrives does not solve response ordering or stale updates.

Swap Units and Values as One State Change

The swap button has a related problem: assignments happen one at a time, but the operation conceptually changes four values together.

function swapUnits() { isSyncing = true; const tmpUnit = fromUnit.value; const tmpVal = fromValue.value; fromUnit.value = toUnit.value; toUnit.value = tmpUnit; fromValue.value = toValue.value; toValue.value = tmpVal; isSyncing = false; persistUnits(); } 

Without the guard, changing the first unit could immediately trigger a calculation using one new unit and one old unit. A later assignment might then swap a value that has already been recalculated from that temporary combination.

The guarded version snapshots the original left unit and value, exchanges the pairs, and only then releases synchronization. It preserves the currently displayed relationship instead of inventing an intermediate conversion.

This is not a general Vue transaction: the assignments still happen separately. The boolean only tells these synchronization functions to ignore intermediate states. Another synchronous watcher would need its own coordination if it depended on the complete pair.

The unit watchers also use synchronous flushing. They call the left-to-right synchronization function and persist unit choices only when the guard is not active. The swap function explicitly persists once after completing the exchange.

That persistence stores the selected unit keys, not the entered measurement. On mount, saved units are loaded if their keys exist in the unit map. The initial values remain deterministic for server rendering, and an initial synchronization already runs during setup so the first rendered result is not just a placeholder zero.

I like this separation: rendering a valid default conversion, restoring preferences, and processing a user edit are distinct operations. They should not depend on a single late initialization step.

Rounding and Validation Still Need Honest Boundaries

The shared helper rounds ordinary finite results to six decimal places, not six significant figures:

function roundLengthResult(v) { if (!isFinite(v)) return 0; return Math.round(v * 1e6) / 1e6; } 

Returning a number avoids padding the display with trailing zeros. It does not preserve arbitrary measurement precision or guarantee a reversible round trip. For example, 0.1 millimeters is 0.0000001 kilometers, which rounds to zero here. A fixed number of decimal places has very different consequences depending on the destination unit.

Swapping the displayed pair preserves the displayed values. It cannot recover digits already removed when a previous conversion was rounded. Likewise, entering a rounded result on the opposite side makes that rounded number the new source of truth.

The shared conversion function also coerces values with Number(value) || 0. Empty or nonnumeric input can therefore produce zero rather than a distinct invalid result. Clearing the left input leaves that source field empty while calculating zero on the right; the guard prevents the derived zero from being written back into the empty field. The numeric inputs have no min restriction, and the helper accepts negative numbers. I would not advertise these fields as a complete domain-validation layer. If an application requires a positive physical length or a separate invalid state, those would be additional rules, not behavior this converter already provides.

Useful tests include editing each side, changing either unit, swapping after editing the right side, and swapping twice. The important assertions concern both numbers and units. Checking only that a familiar conversion appears once would miss the state-transition bugs this design is meant to prevent.

I turned this two-way synchronization example into a small free tool: Length Unit Converter.