DEV Community

Cover image for addDays() Mutated a Date Three Components Away From Where I Called It
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

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:

function nextOccurrence(date, days) {
  date.setDate(date.getDate() + days);
  return date;
}
Enter fullscreen mode Exit fullscreen mode

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

Where's the bug? Look before you scroll

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:

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!
Enter fullscreen mode Exit fullscreen mode

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 fix everyone reaches for — and where it stops scaling

The immediate patch is to clone before mutating:

function nextOccurrence(date, days) {
  const copy = new Date(date); // clone first
  copy.setDate(copy.getDate() + days);
  return copy;
}
Enter fullscreen mode Exit fullscreen mode

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.

What actually fixes the class of bug

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:

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
Enter fullscreen mode Exit fullscreen mode

.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.

🎮 Try it yourself

▶️ Open the interactive playground →

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:

Temporal.PlainDate.compare(
  Temporal.PlainDate.from("2026-09-14"),
  Temporal.PlainDate.from("2026-09-21"),
); // -1 — the first date is earlier
Enter fullscreen mode Exit fullscreen mode

Where Temporal actually stands today

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.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

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:

Top comments (5)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen •

Worth naming the runtime boundary before someone ports a shared date helper on the strength of this. Temporal is in the spec but it is not in Node yet: on Node v25.9.0 here typeof Temporal is undefined and your rewrite throws ReferenceError: Temporal is not defined. --harmony does not change it. Same machine, Chrome 152 has it, and your example runs exactly as written - 2026-09-13 back, standup still on 2026-09-14.

It is a V8 version gap rather than a flag, which is why there is no runtime switch to reach for: Chrome 152 ships V8 15.2, Node 25.9 is still on 14.1. That matters for this specific bug class more than most, because a mutating date helper is precisely the thing that gets shared between client and server code, and the server half is the half where the fix does not exist yet without a polyfill.

Does not change the diagnosis, which I think is right. Just changes where you can apply it today.

Collapse
 
mudassirworks profile image
Mudassir Khan •

the mutation is silent because nothing in the function signature suggests it. Date.setDate() modifies the object in place, and passing that object between components is enough to spread the effect across the whole tree. i've started treating Date objects like objects passed to async calls: clone first, arithmetic second.

the fix that kept us out of this pattern: new Date(date.getTime()) at every utility boundary, before any date math. date-fns bakes this in as default behavior, returning a new Date from every helper.

what's the team policy now? ban setDate across the codebase or enforce a clone convention at the utility layer?

Collapse
 
jo-do profile image
Jo Do •

Mutation bugs are the worst kind of haunting because the crime scene and the corpse are never in the same file. Your standup card didn't move when you clicked "remind me" - it moved whenever the shared Date object next got READ, by a component with no idea it was rendering a stolen value. That's what makes addDays-style APIs so treacherous: the name reads like a pure function and behaves like a break-in. date-fns got this right by making everything immutable and making YOU allocate the new object; moment's mutability is responsible for a measurable slice of all JavaScript grey hair. The defensive habit that survives every library: treat every Date that crosses a component boundary as read-only, and clone at the boundary if you need to move it. The bug you chased for hours costs one Object.freeze of discipline to never have.

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

vinhnguyenthanhdn's ReferenceError is the real blocker right now, not a nitpick. @js-temporal/polyfill is built directly against the same TC39 proposal, so code written against it should need close to zero changes once V8 ships Temporal natively. Still worth checking whether date-fns or luxon, whichever's already in the codebase, already covers the mutation problem before reaching for a third dependency.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.