I think we are going to need different strategies to accomplish all this:
- Minimize unnecessary rerenders.
- Preserve object/array references on mutations.
- A deterministic way to describe mutations for the middleware.
- Patches for devtools, generating immutable snapshots or synchronize clients.
This is what I thought yesterday:
Minimize unnecessary rerenders and preserve object/array references
We can achieve this by never replacing an existing object or array. In order to get that, we need to iterate over the added objects/array and take a look at the current state to see if objects/arrays exist in the same positions. If there’s no such object or array, the iteration finishes and the rest of the data is added. This is to ensure we don’t iterate more than we need because iterating objects is pretty slow. This technique may be slow if the dev is constantly adding the same big object over and over again, but that’s not usually the case.
EDIT: This can be done preserving proxies references. It won’t be slow, at least, it won’t be slower than proxies themselves.
A deterministic way to describe mutations for the middleware
The problem with patches is that they are not deterministic. If the dev does:
state.user = { name: "Jon", surname: "Snow" }
// The patch is:
{ op: "replace", path: "state.user", value: { name: "Jon", surname: "Snow" } }
There’s no way to use that for proper filter of the name value, so I think we need something like “atomic mutation patches” which are deterministic at a scalar level.
state.user = { name: "Jon", surname: "Snow" }
// The mutations are:
{ op: "replace", path: "state.user.name", value: "Jon" }
{ op: "replace", path: "state.user.surname", value: "Snow" }
The problem with this is that in order to emit those atomic mutation patches we would need to iterate over the added object. In case of big objects that can be slow.
In order to avoid unnecessary iterations, we can use a subscription model:
// Dev subscribes to atomic mutation patch
subscribe("state.user.name", myCallback);
// Internally, that is saved like this:
const subscriptions[0] = ["state", "user", "name"];
We can use those arrays to avoid iterating over parts of the object where nobody is subscribed.
Actually, instead of strings, those should be regexps for more complex filtering:
// Dev subscribes to atomic mutation patch
subscribe("state.users.\d+.name", myCallback); // i.e. user[0].name
That syntax will allow subscribing to any mutation in a path, something difficult with mobx-like subscriptions:
// Dev subscribes to atomic mutation patch
subscribe("state.users", myCallback); // any state.user.X.Y.Z mutation
// This doesn't work because it is listening to the users reference
reaction(state => state.users)
We use strings so we lose typescript in that part, but we can use regexps.
Patches for devtools, generating immutable snapshots or synchronize clients
We should still generate regular patches when mutations happen. They serve a different purpose and they are very useful as well. If we don’t need them for either minimizing the rerenders or filtering, they can be just regular, non-deterministic patches like those of immer
.
state.user = { name: "Jon", surname: "Snow" }
// Triggers this patch:
{ op: "replace", path: "state.user", value: { name: "Jon", surname: "Snow" } }
// And executes callback for these subscriptions:
["state", "user", "name"]
["state", "user", "surname"]
// If the object "user" doesn't exist, this mutation occur:
state.user = { name: "Jon", surname: "Snow" };
// If the object "user" exists, these mutations occur:
state.user.name = "Jon";
state.user.surname = "Jon";