Stateify

A simple way to make state management easy.

Wouldn't it be nice if we could just use a regular object to keep track of our state? And use properties as if they were event targets? Well, now you can!

Usage

This small script provides you with a function to turn any JSON data into a state variable. For example:

import stateify from 'stateify'

const data = stateify({
    drinks: ['coffee', 'tea', 'milk'],
    favoriteNumber: 23
})

Then, you can listen to changes to properties simply by adding an event listener like you would with an event target:

data.drinks.addEventListener('change', () => {
    // update component or whatever you need to do
    console.log('drinks changed!')
})

data.drinks.push('water') // "drinks changed!"
data.drinks = ['alcohol'] // "drinks changed!"

The limits

The first thing I should mention is that this only supports valid JSON data, but does not check or convert anything. This means you should not be using any fancy object types, make sure your data does not have any circular references, and does not contain any functions. You can use JSON.parse(JSON.stringify(data)) to check and/or convert your data.

Secondly, since I refuse to add properties or methods to prototypes of built-in objects, necessarily the state variables you get are not actually the values they represent. For example, normally, we'd have this:

const data = {
    foo: 'bar'
}
data.foo.addEventListener('change', () => { ... })
// TypeError: data.foo.addEventListener is not a function

data.foo is just a string, and strings don't have an addEventListener method. I don't want to add one, and so state variables are just proxy wrappers around values. They pretend to be the value you'd expect them to be in most cases, but "magically" allow some methods on them that they don't actually have. They type coerce just like the values they represent, and so you can generally use them as if they actually were. This means you can use them in expressions and in some cases even compare them directly using ==.

import stateify from 'stateify'

const data = stateify({
    drinks: ['coffee', 'tea', 'milk'],
    favoriteNumber: 23,
    preferences: null
})
console.log(data.favoriteNumber + 1) // 24
console.log(data.favoriteNumber + '1') // "231"
console.log(data.favoriteNumber == 23) // true
console.log('I\'ve got ' + data.drinks) // "I've got coffee,tea,milk"

// but, since data.favoriteNumber is a proxy and 23 is not,
console.log(data.favoriteNumber === 23) // false
// and things like this also don't work, because comparing an object to
// an object just checks if they point at the same object in memory
console.log(data.preferences == null) // false
console.log(data.preferences.is(null)) // true

Most of the time, you don't have to worry about any of this because state variables act like they are the value they hold in nearly all cases.

The proxies do make looking at values in the console a bit more difficult - the fact that they're proxies means you don't get any autocomplete and just logging e.g. data.favoriteNumber will log something like Proxy {} (though you can use .get() to read its underlying value).

Composed state variables

Sometimes you'll want to have a value that is dependent on multiple state variables available to you as a state variable itself. Not one you manually change, but one that is composed of others. You can do this by, instead of providing a JSON structure to stateify, providing it with a callback. This callback will create a state variable for you that gets updated anytime its stateified dependencies change. For example:

const state = stateify({
    drinks: ['coffee', 'tea', 'milk'],
    favoriteIndex: 1
})
const favoriteDrink = stateify(() => state.drinks[state.favoriteIndex])
favoriteDrink.addEventListener('change', () => console.log('changed!'))
state.drinks[1] = 'water' // changed!
console.log(favoriteDrink == 'water') // true
state.favoriteIndex = 0 // changed!
console.log(favoriteDrink == 'coffee') // true

While you can still change the composed variable's value using methods like set and delete, you should not do this; the variable will update any time its stateified dependencies change, even if it has been set to a different value manually.

Special methods

These are utility methods any state variable has, that are not actually methods of their value.

is

State variables can be a bit hard to compare because in JavaScript, comparing an object to an object simply checks if the operands point at the same thing in memory. This means you can compare a state variable to a primitive such as 'foo' or 23, but not to an object, which includes other state variables. For example, stateify(23) == stateify(23) returns false even though they both represent 23. Instead, you can use .is(), which compares the state variable it is called on with the argument. For example:

const data = stateify({
    rgb: ['230', '191', '00'],
    red: 230,
    green: 191,
    blue: 0,
    alpha: null
})
console.log(data.rgb[0].is(data.red)) // true
console.log(data.alpha.is(null)) // true

get

Gets the underlying value that a state variable represents. Useful for logging, or if you need to do a === comparison, or maybe you just want to unwrap a state variable to go back to a normal object/array/primitive.

set

Sets the underlying value. Mostly useful for when you're picking properties off an object. For example:

import stateify from 'stateify'

const data = stateify({
    drinks: ['coffee', 'tea', 'milk'],
    favoriteNumber: 23
})

let {favoriteNumber} = data
favoriteNumber.set(7)
console.log(data.favoriteNumber == 7) // true

// this doesn't work because it just reassigns the variable
favoriteNumber = 3

delete

Essentially the same as set, except it does a delete operation. That is, data.foo.delete() is identical to delete data.foo. Like set, this allows you to still delete properties even when picking them off an object.

typeof

Since the typeof operator does not work on state variables (they're all proxies, so typeof will always evaluate to 'object'), this is a way to get the type of a value. variable.typeof() is a shorthand for typeof variable.get().

EventTarget methods

Specifically, addEventListener, removeEventListener and dispatchEvent. While state variables are not technically instances of EventTarget, these methods pass on their arguments to equivalents on an underlying event target.

Events

There are three events that come with state variables.

valuechange

Fires when a property reference is being reassigned. For example, all of the below fire this event on data.drinks:

import stateify from 'stateify'

const data = stateify({
    drinks: ['coffee', 'tea', 'milk'],
    favoriteNumber: 23
})

data.drinks = []
delete data.drinks
data.drinks = 'none'
Object.assign(data, {drinks: ['water']})

propertychange

Fires when a property of the value of a property reference changes. Essentially, this means the contents of a value changes, while the value itself stays the same (and as such it is only applicable to objects). For example:

import stateify from 'stateify'

const data = stateify({
    drinks: ['coffee', 'tea', 'milk'],
    preferences: {
        blackCoffee: false
    }
})

data.drinks[2] = 'water'
data.drinks.push('juice')
data.drinks.sort()
data.preferences.earlGrey = true
delete data.preferences.blackCoffee
Object.assign(data.preferences, {delicious: true})

The above all fire the propertychange event on data.drinks and data.preferences (on whichever is being modified, of course). Note that these event listeners are not tied to the object itself, but rather to the value of the property reference. Essentially,

import stateify from 'stateify'

const data = stateify({
    drinks: ['coffee', 'tea', 'milk']
})

data.drinks.addEventListener('propertychange', () => console.log('Drinks changed!'))
data.drinks.push('water') // "Drinks changed!"

// now we hold a reference to the older array and reassign data.drinks
const olderReference = stateify(data.drinks.get())
data.drinks = ['alcohol']

olderReference.push('juice') // doesn't log anything
data.drinks.push('water') // "Drinks changed!"

change

Lastly, we've got the change event, which fires whenever either valuechange or propertychange does. This event is identical to valuechange for primitive values (because those will never fire propertychange). Most often this is probably the event you'll want to use.

Notes

  • Wrapping the same object in a state variable in different places does not create a different state variable. When providing the same reference, it will result in the exact same state variable. In short, stateify(object) === stateify(object).

  • Built-in methods on any state variable return non-state variable values. So if data.drinks is a state variable for ['coffee', 'tea', 'milk'], then e.g. data.drinks.sort() returns a reference to the underlying array, not data.drinks itself.