← blog
JavaScriptSeptember 12, 2026 · 5 min read

addDays() Mutated a Date Three Components Away From Where I Called It

A date utility that calls .setDate() and returns the same object looks pure. It isn't — Date is mutable, so the caller and everyone else holding that reference get changed out from under them. Temporal, now shipping in real browsers, fixes it by making dates immutable.

Parsa Jiravand · Frontend engineer · building bestpractic
addDays() Mutated a Date Three Components Away From Where I Called It

Someone on the team clicked "remind me" on a Monday standup. Ten minutes later, someone else reported the standup card itself had silently moved to Sunday — on a completely different page, rendered by a completely different component, nowhere near the reminder code.

Nobody had touched the standup's date. The bug was three function calls upstream, in a helper that had shipped, unremarkably, eight months earlier:

JavaScript
1
2
3
4
function nextOccurrence(date, days) { date.setDate(date.getDate() + days); return date; }

It looks pure. Takes a date, takes a number, returns a date. It is not pure, and the reason is Date.

nextOccurrence takes a Date object and returns one. Nothing about the signature says "and also, permanently, edits the object you handed me." But that's exactly what .setDate() does — it mutates the receiver in place and returns a plain number (the new timestamp), which this function throws away before returning date itself.

Here's the shape that broke:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
const standup = new Date("2026-09-14"); // Monday const upcomingEvents = [standup]; // rendered on the events page function reminderFor(event) { return nextOccurrence(event, -1); // "remind a day before" } const reminder = reminderFor(standup); console.log(reminder.toDateString()); // Sun Sep 13 2026 — correct console.log(upcomingEvents[0].toDateString()); // Sun Sep 13 2026 — also changed!

reminderFor(standup) passes the same object reference into nextOccurrence. The .setDate() call inside it edits that object directly. standup and upcomingEvents[0] were never two dates — they were one Date object with two names, and nextOccurrence rewrote it out from under everything else holding a reference. The events page didn't have a rendering bug. It rendered the mutated object perfectly.

The immediate patch is to clone before mutating:

JavaScript
1
2
3
4
5
function nextOccurrence(date, days) { const copy = new Date(date); // clone first copy.setDate(copy.getDate() + days); return copy; }

This works, for this function. It doesn't work as a policy. Every date helper in the codebase now has to remember to clone before it calls any set* method, and there's no error, lint rule, or type that catches the one you forget. setDate, setMonth, setHours, setFullYear, setMinutes — eight mutator methods, and each one is a place a future contributor can reintroduce this exact bug without knowing the convention exists.

And cloning doesn't touch the other classic Date trap hiding one line up: new Date("2026-09-14") — a date-only ISO string — is parsed as UTC midnight. Call .getDate() on it in a browser west of UTC and, depending on the reader's local timezone, you can get the day before the one in the string. Date doesn't distinguish "a calendar date with no timezone" from "a specific instant in UTC" — it collapses both into the same object and makes you guess which one you meant.

Temporal — now part of the language, Stage 4 in TC39 and folded into the ES2026 spec — replaces Date with a set of value types that are immutable by construction. Every method that "changes" a date returns a new one; none of them can touch the object you handed in.

Same helper, same reminder logic, rewritten on Temporal.PlainDate:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
const standup = Temporal.PlainDate.from("2026-09-14"); // Monday, no timezone at all const upcomingEvents = [standup]; function nextOccurrence(date, days) { return date.add({ days }); // always a NEW PlainDate — date itself can't change } const reminder = nextOccurrence(standup, -1); console.log(reminder.toString()); // 2026-09-13 console.log(upcomingEvents[0].toString()); // 2026-09-14 — untouched

.add() (and .subtract(), .with()) hand back a brand-new Temporal.PlainDate and leave standup exactly as it was. There's no clone-before-mutating convention to forget, because there's no mutation to forget it before. And Temporal.PlainDate has no timezone at all — it's a calendar date, full stop — so the UTC-midnight-versus-local-day ambiguity that bit new Date("2026-09-14") doesn't exist here; there was never a timezone to disagree about.

Runs right in your browser — poke at it and watch the concept react live.

When you do need to compare two dates rather than reach into their fields by hand, Temporal.PlainDate.compare() gives you a real answer instead of falling back to arithmetic on timestamps:

JavaScript
1
2
3
4
Temporal.PlainDate.compare( Temporal.PlainDate.from("2026-09-14"), Temporal.PlainDate.from("2026-09-21"), ); // -1 — the first date is earlier

This isn't a future-tense proposal anymore. Temporal reached TC39 Stage 4 in March 2026 and shipped as part of ES2026. It's already running natively: Chrome and Edge 144 (January 2026) and Firefox 139+ ship it without a flag. Safari doesn't yet — it's behind a flag in Technology Preview, not in a stable release, which is also why MDN can't mark it "Baseline" yet: that label needs the widely-used browsers in agreement, and Safari hasn't caught up.

For anything shipping today, that means: reach for it directly if your audience is Chromium- or Firefox-heavy, or pull in temporal-polyfill (or the TC39-maintained @js-temporal/polyfill) for the rest — both implement the same spec, so the code you write against the polyfill today keeps working unchanged once Safari catches up and you drop it.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The standup card wasn't a rendering bug, and nextOccurrence wasn't a badly written function — it was a completely reasonable function written against an object model that lets any caller rewrite any other caller's data by accident. That's not a bug you fix once. It's a bug class you either keep guarding against by hand, function by function, forever — or hand to a type that structurally can't have it.

Grep your own codebase for a helper that calls setDate, setMonth, setHours, setFullYear, or setMinutes on an argument and then returns it. What did you find?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.