6eda1e4e924f351cbe50a8166fb6dfe45c9928d5 changed the API of immutable tesseract as proposed by @orionz, namely eliminating the distinction between a store and the root object, and applying edits by calling tesseract.something()
functions rather than methods. @adamwiggins provided a DX critique of the API style. In this issue I'd like to discuss the options further.
Multi-field assignment problems
One rationale for the change was that in the following example (from the previous API) it's not clear what's happening:
store = store.assign({config: {background: 'blue'}})
// That might be equivalent to setting a single field in the config object:
store.config.background = 'blue'
// or it might mean replacing the entire config object:
store.config = {background: blue}
The previous API merged objects on a field-by-field basis if the object already existed (i.e. equivalent to store.config.background = 'blue'
), which seemed intuitive to me, but then Orion pointed out that it is inconsistent with JavaScript's Object.assign()
. Also, that API left no way of replacing an entire object, except perhaps by deleting the entire object and re-adding it.
So, in the new API, I didn't include any method for assigning to several fields at a time (by passing an object), and only allowed a single field to be updated at a time. However, the new API still allows you to pass an object as a value:
t.insert(s1.cards, 1, {title: 'Reticulate splines', done: false})
// The line above is shorthand for:
s1 = t.insert(s1.cards, 1, {})
s1 = t.set(s1.cards[1], 'title', 'Reticulate splines')
s1 = t.set(s1.cards[1], 'done', false)
Adam felt this API was inconsistent, because t.set(s1.cards[1], 'done', true)
just takes a primitive value as third parameter, whereas t.insert(s1.cards, 1, {title: 'Reticulate splines', done: false})
takes an object as parameter. I agree that they look visually different, but I think they actually have the same structure: both insert
and set
have arguments (targetObject, keyOrIndex, value)
, and the change always affects the single field targetObject[keyOrIndex]
, regardless of whether value
is a primitive or an object.
I think it's important for the API to encourage the developer to only assign a single field at a time, which is required in order to get sensible CRDT merging behaviour (I think @choxi called this principle something along the lines of "make changes as granular as possible"). My feeling is that the single-field set
operation does that, but perhaps there's room for improvement.
It would be possible to define a two-argument version of set
, as Adam proposes:
t.set(s1.cards[1], { done: true })
// is equivalent to:
t.set(s1.cards[1], 'done', true)
which would allow several fields to be assigned in one go, but I'm not sure it would be much clearer. Any thoughts?
Functions versus methods
Compare:
// Current API uses function insert on the global tesseract object:
s1 = t.insert(s1.cards, 0, {title: 'Rewrite everything', done: false})
// Adam suggested instead putting a method on the store object:
s1 = s1.insert('cards', 0, {title: 'Rewrite everything', done: false })
My reason for using the first of the two options was to keep the namespaces of tesseract methods and application data separate. For example, say the application sets a field called insert
:
…then it is not clear whether s1.insert
refers to the insertion method or the value 3
. The previous API relegated application data to the root object, so s1.root.insert == 3
, but having to constantly type .root
gets tedious quickly.
If we want to use methods, one option would be to prefix them with an underscore, since the application data is not allowed to have fields starting with an underscore:
s1 = s1._set('set', 3)
// now s1._set is a function, and s1.set == 3
s1._set('_set', 3) // not allowed, throws an error
…but that seems a bit ugly to me.
Structure-sharing
One attractive property of immutable data structures is that when some data changes, the object references for unaffected parts of the tree remain unchanged, and only changed parts of the tree get new object references. This means that you can check whether something has changed by doing a simple pointer equality comparison ===
, and AFAIK this property is used for rendering efficiency by React and Redux.
However, the current API does not have that property: since it creates proxy objects on the fly, the references to objects are never the same. For example:
s1.cards[1] === s1.cards[1]
// surprisingly, returns false!
I can't think of a way of avoiding this problem with the current API. The issue is as follows: for example, if you do t.set(s1.cards[1], 'done', true)
, the function needs to return a new root object that is the same as s1
except that it has s1.cards[1].done
updated. The t.set()
function is only passed s1.cards[1]
as an argument (i.e. a single card), and it needs to find the root object from there, which requires including a reference to the root object on the nested card object. Now, whenever anything changes, we get a new root object, and so all nested objects that reference the root object also need to be replaced with new objects that reference the new root. Thus, this API forces us to sacrifice either structure-sharing or immutability. (Currently, it sacrifices structure-sharing.)
To fix this, we would need to change the API so that we pass the root object, and not some nested object, into the function that performs the change. For example, one of the following:
// All of these are equivalent to s1.cards[1].done = true
s1 = t.set(s1, ['cards', 1, 'done'], true)
s1 = t.set(s1, 'cards', 1, 'done', true)
s1 = s1.set(['cards', 1, 'done'], true)
s1 = t.set(s1, s1.cards[1], 'done', true)
In other words, the code would probably have to spell out the path from the root object to the updated object as a list of strings/numbers, rather than the more natural JS expression s1.cards[1]
. Alternatively, as in the fourth example above, you would need to pass both the root object (s1
) and the object being updated (s1.cards[1]
) as two separate arguments.
Opinions welcome!