A calculator with two inputs and one output is the "hello world" of interactive UI. It is also a small minefield.
These are three defects I hit while building bidirectional CPM ↔ CPC conversion, and the fixes are portable to any numeric form.
Bug 1: percentage drift
The formula is simple once you see it. Cost per thousand impressions, divided by clicks per thousand impressions, gives cost per click.
The bug is in the middle term. CTR is stored as a percentage — 2 means 2%. So how many clicks happen per 1,000 impressions?
// wrong: treats 2 as 2 clicks per 1000
const clicksPerThousand = parsedCtr;
// wrong: treats 2 as 2000 clicks per 1000
const clicksPerThousand = parsedCtr * 1000;
// right
const clicksPerThousand = parsedCtr * 10;
Why 10? Because CTR is per hundred impressions (that is what percent means) and CPM is per thousand:
clicks per 1000 = 1000 × (ctr / 100) = ctr × 10
Writing the derivation as a comment next to the constant is worth more than a clever variable name. Off-by-1000 errors in ad math are almost always this line.
Once you have it, both directions are trivial and symmetric:
const convertedValue =
mode === "cpm-to-cpc"
? parsedCost / clicksPerThousand
: parsedCost * clicksPerThousand;
One division, one multiplication, same intermediate. There is no reason to write two separate formulas and let them drift apart.
Bug 2: validation that lets an empty field through
const parsedCost = Number(cost);
const parsedCtr = Number(ctr);
Number("") is 0. That fails the <= 0 check here, so it happens to be safe — but that is luck, not design. The moment you add an optional field with a legitimate zero (0% CTR as a valid "no clicks" input, or a 0 budget floor), the empty string slides straight through as a real zero.
Be explicit about what is allowed:
if (
!Number.isFinite(parsedCost) ||
parsedCost <= 0 ||
!Number.isFinite(parsedCtr) ||
parsedCtr <= 0 ||
parsedCtr > 100
) {
setResult(null);
setMessage(
"Enter a cost above zero and a CTR greater than 0% and no more than 100%.",
);
return;
}
Three things this gets right:
-
Number.isFinitecatchesNaNandInfinity.Number("abc")isNaN, andNaN > 0isfalse, so it would be caught — butNumber("1e999")isInfinity, which passes> 0. Explicit finiteness checks stop the whole class. -
The upper bound is enforced. A CTR above 100% is impossible under a one-click-per-impression model. If your data source allows multi-count attribution, that assumption changes, and the validation should change with it — but silently accepting
500as a CTR is worse than rejecting it. - The result is cleared on failure. This is the part people forget, and it is actually Bug 3.
Bug 3: stale results after an invalid submit
Consider the sequence:
- User enters valid values. Result renders: $0.50.
- User edits the cost field, leaving it empty.
- User submits.
- Validation fails, an error message appears — and the old $0.50 is still on screen.
The message says the input is bad. The screen still shows a number. Users read the number.
if (!valid) {
setResult(null); // <- the important line
setMessage("...");
return;
}
Clearing the result is not cosmetic. It keeps the error state and the displayed state consistent: either there is a valid result, or there is an explanation. Never both.
The same rule applies to switching modes:
const changeMode = (nextMode: Mode) => {
setMode(nextMode);
setCost("");
setCtr("");
setResult(null);
setMessage("");
};
If you flip from CPM → CPC to CPC → CPM and leave the old result on screen, you have displayed an output computed under the other mode's formula, next to inputs that no longer mean the same thing.
The formatting is part of the correctness
Formatting a CPC with Intl.NumberFormat and the default two fraction digits throws away the value users need:
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
const number = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
});
Two formatters, deliberately. Money gets four fraction digits because low CPC values are legitimately sub-cent: $0.0012 displayed as $0.00 is not a rounding choice, it is a wrong answer. Counts and percentages get two digits, because "1,234.5678 impressions" is noise.
Also note the formatters are created once, at module scope. Constructing an Intl.NumberFormat is not free, and doing it inside a render body on every keystroke is a measurable waste in a form that re-renders on every input change.
Every number needs a label
The most common defect in calculators is not arithmetic. It is an unlabeled number.
0.5 could be a CPC in dollars, a CTR in percent, or a conversion rate. When a user screenshots a result, the label has to travel with the value. Carry the input alongside the output in the result object so the UI can render "at $10 CPM and 2% CTR" beside the number:
type Result = {
convertedValue: number;
clicksPerThousand: number;
inputValue: number;
};
setResult({ convertedValue, clicksPerThousand, inputValue: parsedCost });
This also makes the result testable as data rather than as rendered markup.
The portable checklist
- Write the unit derivation next to the constant, not in your head.
- Make inverse operations share one intermediate value.
- Check
Number.isFiniteexplicitly; do not rely on comparison operators to catchNaN. - Enforce upper bounds that the domain implies, and document the assumption behind the bound.
- Clear stale output whenever input becomes invalid or the mode changes.
- Use
Intl.NumberFormatwith enough fraction digits for the smallest meaningful value. - Build formatters once, outside the render path.
- Never display a number without its unit and its inputs.
These three fixes came out of Get CPM Calculator, a set of advertising calculators for CPM, CPC, impressions, and break-even ROAS. The arithmetic is the easy part; the state handling is what determines whether people trust the number on the screen.
Disclosure: Get CPM Calculator is my own project. Results are calculations from the values you enter, not platform benchmarks.
Top comments (0)