Skip to the code
cf.

Immutable setIn

Write setIn(object, path, value), where path is an array of keys. It returns a new version of object with value at path, and leaves object and everything in it unchanged, the way state updates work in React or Redux. You can assume the containers are plain objects and arrays.

Copy only what's on the path: object itself and each object or array you step through (arrays stay arrays). Every branch off the path is shared, by reference, with the original, so comparing the old and new versions with === shows exactly what changed.

When a step is missing or isn't an object, create it: an array if the key that goes into it is a number, a plain object otherwise. If the value at path is already value (by Object.is), return object itself, since nothing changed. An empty path returns value.

const state = { user: { name: "Ada", address: { city: "London" } }, posts: [] };
const next = setIn(state, ["user", "name"], "Grace");
 
next.user.name;                           // "Grace"
state.user.name;                          // "Ada"
next.user === state.user;                 // false: on the path, so copied
next.user.address === state.user.address; // true: off the path, so shared
next.posts === state.posts;               // true

What the tests check

  1. returns a new object with the value set
    const state = { user: { name: "Ada", age: 36 } };
    expect(setIn(state, ["user", "name"], "Grace")).toEqual({ user: { name: "Grace", age: 36 } });
    
  2. leaves the original unchanged
    const state = { user: { name: "Ada" } };
    const next = setIn(state, ["user", "name"], "Grace");
    expect(state).toEqual({ user: { name: "Ada" } });
    expect(next).not.toBe(state);
    expect(next.user).not.toBe(state.user);
    
  3. shares every branch off the path
    const state = { user: { name: "Ada", address: { city: "London" } }, posts: [{ id: 1 }], settings: { theme: "dark" } };
    const next = setIn(state, ["user", "name"], "Grace");
    expect(next.user.address).toBe(state.user.address);
    expect(next.posts).toBe(state.posts);
    expect(next.settings).toBe(state.settings);
    
  4. copies arrays as arrays
    const state = { list: [1, 2, 3] };
    const next = setIn(state, ["list", 1], 20);
    expect(Array.isArray(next.list)).toBe(true);
    expect(next.list).toEqual([1, 20, 3]);
    expect(state.list).toEqual([1, 2, 3]);
    
  5. creates missing steps, with arrays for number keys
    expect(setIn({}, ["a", "b", 0], "x")).toEqual({ a: { b: ["x"] } });
    expect(setIn({ a: 1 }, ["a", "b"], 2)).toEqual({ a: { b: 2 } });
    
  6. returns the original when the value is already there
    const state = { user: { name: "Ada" }, score: NaN };
    expect(setIn(state, ["user", "name"], "Ada")).toBe(state);
    expect(setIn(state, ["score"], NaN)).toBe(state);
    
  7. an empty path returns the value
    expect(setIn({ a: 1 }, [], 5)).toBe(5);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).