Ramonda

Immutable updates

import { focusOn } from "@ramonda/lens";

this.data = focusOn(this.data)
  .get("posts")
  .where((post) => post.id === 102)
  .get("tags")
  .where((tag) => tag === "draft")
  .set("published");

Nothing is mutated. The result shares every object that was not on that path — the other users, the other post, the untouched tag — and has a new object for each of the four levels that were: the root, posts, posts[1], and posts[1].tags.

No proxies. Nothing is read, copied or wrapped until a terminal operation runs; the chain only records where to go.

Why the shape of the result matters

Ramonda's diff shallow-compares. A branch whose reference did not change is rejected on a === and never walked into. That only works if untouched really means identical — which is the guarantee this package exists to provide, and the reason it lives next to the framework rather than being any immutable-update helper.

last: nothing yet

    Only what is ON the path gets a new object. `state.users` and `state.posts[0]` come out of every edit as the very same objects, so a shallow compare rejects those branches without looking inside them — which is what makes the diff cheap. Write a value that is already there and nothing is copied at all, not even the root.

    Press the buttons and watch which rows say same object. Those are the branches the diff will skip.

    The guarantee holds in the other direction too. A write whose value is already there produces no copies at all and returns the original root:

    focusOn(state).get("title").set(state.title) === state; // true
    

    So does a merge of unchanged fields, and an update that returns its input. A no-op cannot invalidate the path above it.

    The pairing with list()

    list() binds an item's identity to its object. An immutable edit gives the edited item a new object — which is exactly right for the diff, and has one consequence worth knowing before you meet it:

    In a list without key, the edited row is a new entity, so that row's component state resets. Every other row is untouched.

    Nothing lands on the wrong row — state is never wrong, only reset — but when a row owns state you care about, that is when key earns its place:

    list({
      each: this.posts,
      key: (post) => post.id,   // "this is still the same entity"
      as: PostRow,
    })
    

    focusOn and key belong together whenever list items own state. Neither is needed for a list of plain markup.

    Why not a draft-mutating library

    The usual alternative hands you a proxied draft to mutate. It produces the same structural sharing, so the difference is not correctness:

    • No proxy means no draft that can escape its producer, and no finalize pass over the result.
    • Scanning is not pathological. Searching a draft drafts every element it touches, so draft.posts.find(…) over 5000 records allocates 5000 proxies. Describing the search as part of the path does not.
    • It is about 1.2 KB, and has no dependencies.

    On like-for-like paths the speed difference is modest — both approaches copy only what is on the path, and that costs the same. The numbers, including the ones that flatter neither side, are in the package README.

    Next