Moment.js to Date-fns Migration
During a few of our quarterly innovation sprints (sprints where we focus on new innovative ideas or approaches), we thought it would be a good idea to sunset Moment.js and migrate to a more modern approach to dates.
Why Did We Migrate?
To put it plainly, we were forced to migrate. We received the sad news on September 2020 that Moment.js would no longer be maintained. Moment.js has served faithfully on a vast number of websites over the history of the web, but in recent years there have been a surge of more modern and lighter replacements. Additionally, the bundle size of Moment is large, and the fact that it provides no tree shaking meant that we would have to load the entire library on every file that used Moment. Lastly, our engineering team was zealous for as much “correct” practices as possible, so we wanted a library that promoted immutability.
Sure, we could’ve just used vanilla JS. But for the amount of formatting we did as an investment company, it made sense to just to use one of the new fancy libraries, which led us to Date-fns.
The Process
It took a very long time to go through our entire codebase in order to convert every instance of Moment to Date-fns. We began with our API, storybook, and utilities repos. After those were completed, the busy season created an awkward hiatus where our engineers had to learn Date-fns if they needed to work on the back-end but still rendered dates with Moment on the client. But, after a few months later, we finally had enough time to get to the biggest hurdle, the client, in order to finally nuke Moment.js for good (R.I.P 🪦 ).
The Challenges
1. Reading From the Inside-Out
The first hurdle was getting through the initial understanding of how Date-fns works. The coolest thing about Moment was that a function always returned a Moment object, which allowed for chaining, and consequently, easier-to-read code. For example:
moment().add(1, "months").format("MM/DD/YYYY");
Even if you’ve never used Moment, you can guess pretty easily what's happening if you read from left to
right. First, we create a Moment object without any arguments, which defaults to today’s date. We add 1 month, and return a
date string with the format of MM/DD/YYYY. So, if today is May 25, 2022, the resulting string would be 05/25/2022
.
But in Date-fns, you would read from the inside out. The same operation would read like this in Date-fns:
format(addMonths(new Date(), 1), "MM/DD/YYYY");
Notice that the first operation is the deepest: creating a new JS Date object with today’s date. Then, we
wrap that in addMonths()
, add 1 as the second parameter, then wrap that in a format function and pass MM/DD/YYYY
as the second parameter.
2. Replacing moment()
The second coolest thing about moment is how concise the moment
function is at parsing out date strings. Date-fns has a parse
function, but it is much more verbose than calling moment()
. Which is why created our own helper function to simplify the migration process. You can find the function at the bottom of the article.
A Few More Differences
But even with some noticeable differences, using Date-fns starts becoming more and more intuitive after a while. But there were a few more differences we realized we had to keep track of. Firstly, Moment can guess some formats without even being explicit:
const completionDate = "2020-11-13";
moment(completionDate).isValid(); //true
isValid(parse(completionDate, "", new Date())); // true
const completionDateStardard = "11/13/2020";
moment(completionDateStardard).isValid(); //true
isValid(parse(completionDateStardard, "", new Date())); // false
const completionDateTwoDigitYear = "11/13/20";
moment(completionDateTwoDigitYear).isValid(); //true
isValid(parse(completionDateTwoDigitYear, "", new Date())); // false
Moment can guess the standard US format of MM/DD/YYYY and MM/DD/YY whereas Date-fns cannot. In this case, I believe it makes more sense to be as explicit as possible, but there were quite a few instances of past developers letting Moment do the guess work.
Another difference is the fact that Date-fns does not allow adding floating years and months:
const isOldEnoughMoment = moment(birthday, "MM/DD/YYYY")
.add(59.5, "years")
.isBefore();
// half years need to be calculated as 6 months with Date-fns
const isOldEnoughDateFns = isBefore(
addMonths(addYears(parse(birthday, "MM/dd/yyyy", new Date()), 59), 6),
new Date()
);
But the biggest headache was working with UTC time. A whole separate blog post could be written about migrating code that used UTC time, but in summary, the benefit of the Moment object is that it can store time zone data, whereas vanilla Date objects cannot. Therefore, if we are passing dates over multiple files, it is important to keep track of what was originally intended with the date. The following is a small example of what I mean:
import { format as formatTZ, utcToZonedTime } from "date-fns-tz";
// ---------------------------
// Now in EDT via Moment
// ---------------------------
const nowInESTMoment = moment().tz("America/New_York");
// ...in another file
nowInESTMoment.format("LLL z"); // May 25, 2022 1:55 PM EDT
// ---------------------------
// Now in EDT via Date-fns
// ---------------------------
const nowInETDateFns = utcToZonedTime(new Date(), "America/New_York");
// ...another file
// Date-fns format without time zone
format(nowInETDateFns, "MMMM d, yyyy p z"); // May 25, 2022 1:55 PM GMT-7
// date-fns-tz format with time zone specified
formatTZ(nowInETDateFns, "MMMM d, yyyy p z", { timeZone: "America/New_York" }); // May 25, 2022 1:55 PM EDT
Notice that Moment keeps track that the timestamp was converted to EST/EDT and can easily format with the
correct specified time zone. Whereas Date-fns does not have the correct time zone unless we use the
date-fns-tz format()
function and explicitly provide the correct time zone.
Conclusion
Nonetheless, all the hard work eventually paid off, and our bundle size for our app decreased significantly with the removal of Moment. Date-fns is awesome and will most likely be used in many newer apps for the next few years. But I look forward to a day where we can use a more native approach, like the upcoming Temporal object!
/**
* Get Date Helper Function for Date-fns
* Parse the input date with a given input format into a Date object
* @param [date] - the date specified in a string, Date, or number
* @param [inputFormat] - what format the input date is currently in, using date-fns format patterns - https://date-fns.org/v2.19.0/docs/format
* @returns a Date object parsed from the input date string, Date, or number
*/
export const getDate = (
date?: string | Date | number,
inputFormat?: string,
): Date => {
const invalidDate = new Date('Invalid Date');
if (date instanceof Date) {
return date;
} else if (typeof date === 'number') {
return new Date(date);
} else if (typeof date === 'string') {
if (typeof inputFormat === 'string' && inputFormat !== '') {
const splitDate = date.split(/[^0-9]/g) || [];
const firstVal = splitDate[0] || '';
const secondVal = splitDate[1] || '';
const thirdVal = splitDate[2] || '';
// perform extra checks for specific input formats
switch (inputFormat) {
case DateFormat.StandardSlash:
return firstVal.length <= 2 &&
secondVal.length <= 2 &&
thirdVal.length === 4
? parse(date, inputFormat, new Date())
: invalidDate;
case DateFormat.MonthYearSlash:
return firstVal.length <= 2 && secondVal.length === 4
? parse(date, inputFormat, new Date())
: invalidDate;
default:
return parse(date, inputFormat, new Date());
}
} else {
return parseISO(date);
}
} else {
// moment(undefined, format) returns an Invalid Date object, so need to replicate that behavior here
return inputFormat ? invalidDate : new Date();
}