Updating
Every write returns the new root, so the result is what you assign back:
this.data = focusOn(this.data).get("title").set("Renamed");
.set(value) |
Replaces the focused value. |
.update(fn) |
Replaces it with fn(current). |
.merge(partial) |
Copies the focused object and assigns over it. |
.remove() |
Drops the property or element from its container. |
.push(...items) |
Appends to the focused array. |
.insert(i, ...items) |
Inserts at a position. i === length appends. |
.and(...branches) |
Forks the path โ several edits, one walk. |
Setting a key that is not there yet creates it. draft?: boolean is a key TypeScript accepts,
so refusing it at runtime would make the API disagree with its own types โ and with merge, which
would have added the same field. A typo in the middle of a path is still reported and still
changes nothing, because there the value cannot be descended into at all.
Several edits in one pass
A chain describes one path. and forks it: each branch walks on from that value and returns the
new version of it.
focusOn(state)
.get("posts")
.where((post) => post.id === 102)
.and(
(post) => post.get("title").set("Renamed"),
(post) => post.get("tags").push("published"),
);
Everything above the fork is copied once, not once per branch. That is the whole reason it
exists rather than feeding each result into a new focusOn โ the shared prefix is walked a single
time. On a 5000-post state, three edits through one fork measured 7.9 ยตs against 21.7 ยตs for
three separate chains.
Three details worth knowing:
- Branches run in order, and each sees the previous one's result. Applying them all to the original and combining afterwards would let the last branch silently discard the others.
- A branch receives a focus rooted at the forked value, so its terminal operation returns the new value of that node. That is why the types line up with no new concept to learn.
- Forks nest, and a fork under
whereapplies to every match.
For several fields of the same object, merge is shorter:
focusOn(state).get("posts").at(0).merge({ title: "Renamed", draft: true });
Reach for and when the branches go to different depths, or need different operations.
One chain, one write
Sharing a prefix to read is fine โ hops return new chain objects, so nothing is mutated:
const posts = focusOn(state).get("posts");
posts.where((p) => p.draft).values();
posts.where((p) => p.id === 1).value();
Writing through the same focusOn twice is not, and throws in development:
const posts = focusOn(state).get("posts");
posts.at(0).get("title").set("one");
posts.at(1).get("title").set("two"); // throws
focusOn(root) captures root once, so the second write would be computed from the original
value and would silently discard the first edit. The result looks plausible and is missing a
change, which is far harder to find than a throw.
Fork instead, or feed the result back in:
const next = focusOn(state).get("posts").at(0).get("title").set("one");
const after = focusOn(next).get("posts").at(1).get("title").set("two");
What cannot be traversed
Map, Set and Date are fine as values โ set(new Date()) stores one like any other leaf.
A path cannot descend into one: their contents live in internal slots that a copy cannot reach,
so a chain that tries reports it and changes nothing.
Class instances are traversed normally. Copies preserve the prototype and copy descriptors, so an instance stays an instance and a getter stays a getter.
Diagnostics
In development, a path that cannot be reached reports itself and changes nothing: a missing
property mid-path, a null on the way down, an index out of range, a where that matched nothing,
a branch that forgot to return. All of it is compiled out of the production build, along with the
double-write guard.
Next
- Rendering lists โ where the new object identity an edit produces starts to matter.