Dates in Vuex: Primitives or Complex Objects?

Michael Gallagher
DailyJS
Published in
7 min readAug 1, 2018

What is the Recommendation?

Like many modern state management tools, Vuex is inspired by the Flux pattern, and the recommendation for these libraries is to keep logic and data separate. The state within Vuex should go through deterministic mutations and can be time-travel debugged.

Example of Vuex storing a complex object

Above is some sample code which uses Vuex to store a “complex object”, let’s give that a definition:

An object which has mutation logic attached to it, which means its value is not exclusively managed by the store, and the store’s mutations.

If you look in the console for this JSFiddle, you’ll see:

Error: [vuex] Do not mutate vuex store state outside mutation handlers.

This is when we are running in non-production and strict mode, in production, this action would be permitted, but time-travel debugging would not register a change and if the user were to navigate the state, unexpected things would happen.

Just to be clear, the above example is what is not recommended.

Not so Black and White

So let’s look at the list of constructs we can use within the state, starting with JSON, which is a data-interchange format, no functions allowed.

According to MDN, ECMAScript primitives include Booleans, Numbers, Strings and some others we’ll ignore for right now (null, undefined, Symbol).

Object is also mentioned as a data type, though not a primitive, we know JSON can hold plain objects. JSON also allows Arrays, and we know Vuex does too, but consider that [] instanceof Object is true, and Array.prototype contains mutating logic.

In fact, Arrays are troublesome enough that the Vue docs have a caveats section about them. We need Arrays, but it has to be said, they don’t obey the primitive/simple object rules.

Hold up, don’t primitive types have a whole host of mutating functions too?

3.4.toFixed() // 3
'abc'.slice(1) // bc

Actually, if you review the list, you’ll find that none of these functions actually mutate the primitive itself, but rather return a new value. Despite ECMAScript’s unorthodox Object-like behaviour on primitives, they are at least immutable.

…and Date?

No sign of Date mentioned, isn’t it a primitive in Javascript? No, it needs to be instantiated (new Date()), Date instances are Object instances, and lastly, they are most definitely mutable (e.g. setMinutes).

So what is the JSON recommendation for storing dates?

There isn’t one. Some people stored them as:

  • Unix epoch milliseconds — 1532611488
  • ISO 8601 String — 2018–01–01T01:01:27.511Z

In fact, the Date.toJSON function will serialise a Date instance to ISO 8601 String.

Does this mean Date instances can’t be used in Vuex?

With time travel debugging, the state of the store needs to be snapshotted and restored as the user navigates the history. To do this it is necessary to cleanly replicate the state for each snapshot, i.e. perform a deep clone.

A common way to deep clone simple data is: JSON.parse(JSON.stringify(data))

But we have seen above that a Date instance is serialized as a String in JSON, so this above clone operation will give a String instead of a cloned Date instance.

However, the Vue devtools code has complex object logic in its clone operation which includes Date instances.

…so time-travel debugging will work just fine with Date instances.

Let’s see what happens when we try to mutate a reactive Date instance.

As expected, internal Date instance mutation isn’t reactive.

Take a Moment

I think it is safe to say there is a lot of grey area around dealing with Dates within any reactive Vue data, like the state in Vuex.

Here’s a suggestion, what if we wanted to persist a Moment.js instance? With 38k GitHub stars, it is a standard in itself for Date manipulation in Javascript. Aside from being 3rd party software, it behaves much like the native Date object.

Just as with a Date instance, any internal mutation will not trigger reactivity.

Moment.js documentation points out the mutating nature of its functions, but recommends using clone if a new copy is needed.

mom = mom.clone().add(7, 'days’)

If you like using ESLint, you could write a rule to catch usages of mutating methods which don’t follow a clone call.

Time-travel Debugging

Although there is special logic for Date instances, we know there is no special logic for saving a Moment.js instance and reinstating it correcting.

So how can this be solved?

If you inspect the time travel debugging code you’ll find it exclusively uses replaceState which takes a new state object as a parameter. Below is a code snippet which modifies the replaceState function on a Store instance (store).

const deserializeMoments = (obj) => {
const queue = [obj];
while (queue.length) {
const item = queue.shift();
if (typeof item === 'object' && item !== null) {
if (Array.isArray(item)) {
queue.push(...item);
} else if (item._isAMomentObject) { // Found a Moment instance
Object.setPrototypeOf(item, moment.prototype);
} else {
queue.push(...Object.values(item));
}
}
}
};
const oldReplace = store.replaceState;
store.replaceState = function replaceState(state) {
deserializeMoments(state);
return oldReplace.call(this, state);
};

With replaceState extended to correctly restore Moment.js instances, there is only one other problem scenario to deal with.

Reactivity Spill

Moment.js instances' internal properties contain references to shared objects.

devtools Debugging of Moment.js instance

Here the Locale is actually shared data, also if moment-timezone is used, the Timezone of the date is stored under _z.

If Vuex is permitted to traverse this data and reactivify it, any changes to this shared data will trigger reactivity and also fire the warning from the top of the article.

Assuming we want to exclude this data from reactivity, how could we do it?

If you read the traversal code for reactivity, you will see it uses Object.keys to find out what properties of an object should be made reactive. And that method returns:

An array of strings that represent all the enumerable properties of the given object.

To hide data from reactivity, it needs to be made non-enumerable. However any properties made non-enumerable will be lost during time-travel debugging, as JSON.stringify only serialises enumerable properties.

So the idea is to avoid reactifying Locale and Timezone data, while still maintaining the link between the Moment.js instance and that data.

All the Locales and Timezone records can be accessed globally under Moment.js, if all the properties in those objects are hidden from reactivity (i.e. made non-enumerable), then any Moment.js instance which references them will not expose that data to reactivity. In order to maintain the reference, the ID property of those records needs to be kept enumerable. This reactivity is safe as the ID property of a record should never change.

// Make all fields on `object` hidden (not enumerable)
// except the idProp.

const hideFields = (object, idProp) => Object.keys(object)
.forEach((field) => {
if (field !== idProp) {
const val = object[field];
Object.defineProperty(object, field, {
value: val,
configurable: true,
writable: true,
enumerable: false,
});
}
});

moment.locales().forEach((code) => {
const locale = moment.localeData(code);
hideFields(locale, '_abbr');
});
moment.tz.names().forEach((tz) => {
const zone = moment.tz.zone(tz);
hideFields(zone, 'name');
});

The above code uses Object.defineProperty to redefine these properties and make them non-enumerable. The above code needs to run before any Moment.js instances are added to the store.

Next, the deserializeMoments function needs two extra steps.

...} else if (item._isAMomentObject) { // Found a Moment instance
Object.setPrototypeOf(item, moment.prototype);
// Re-read the TZ and Locale shared objects
// (only the identifier fields are in the snapshot)

item.tz(item.tz());
item.locale(item.locale());
}
...

This step will restore the link to the global Locale / Timezone record, which will still have all the properties (despite being non-enumerable). The value returned from the getter functions above will be the ID property for the record within the Moment.js instance, which will be maintained during time-travel debugging.

Summary

Using String or Number types to represent Dates, in Vuex, is intuitive and simple. Strings are easy to read in the devtools, although each operation on them will likely require a parse, Numbers on the other hand is less costly to instantiate, but not very useful in devtools.

Storing Date or Moment.js instances means:

  • No on-the-fly parsing/instantiation of Date instances is necessary
  • More concise getters and mutations within the store
  • Internal Mutation needs to be avoided
  • Time-travel debugging needs to properly deserialize the object
  • Reactivity spill needs to be managed

If you are hesitant to use a complex object, remember that Arrays are complex objects. That at the end of 2017, code to support Date instances was specifically committed to Vue’s devtools for time-travel debugging.

If you consider the above changes brittle, remember than replaceState is a public interface on the store and the above code works like the proxy pattern. The Moment.js library is very mature, the instance properties have not changed in at least three years (Jan 2015 some repo changes means it is difficult to see further back in the history).

And if you are still opposed to my suggestions, hopefully at least you’ve learned something about Vuex :)

--

--

Michael Gallagher
DailyJS

Living in the vibrant city of Buenos Aires, developing eCommerce software with Hip. Modern JS is my passion, Vue my tool of choice