Skip to content

New: Use immer for safe immutability in GraphQL-enabled apps

Background

Apollo Client 2.6 introduced immutability of cache result objects, which will be a requirement for Apollo Client 3.0. Blog post with rationale

This change is already picked by GitLab in gitlab-org/gitlab!19236 (merged) and we're required to update our relevant writeQuery calls to be immutable

We're already refactoring code (gitlab-org/gitlab#35263 (closed)) to support new pattern: for example gitlab-org/gitlab@455a60db via gitlab-org/gitlab!19536 (merged)

New Pattern Proposal: Use immer for safe immutability in GraphQL-enabled apps

Before updating to Apollo 2.6 (source):

const data = store.readQuery({ /* ... */ });
const currentDiscussion = extractCurrentDiscussion(data.design.discussions, this.discussion.id);
currentDiscussion.node.notes.edges.push({ __typename: 'NoteEdge', node: createNote.note });
data.design.notesCount += 1;
store.writeQuery({ query: getDesignQuery, data });

Our current state (source):

const data = store.readQuery({ /* ... */ });
const currentDiscussion = extractCurrentDiscussion(data.design.discussions, this.discussion.id);
currentDiscussion.node.notes.edges = [ 
  ...currentDiscussion.node.notes.edges,
  { __typename: 'NoteEdge', node: createNote.note },
];

store.writeQuery({ query: getDesignQuery, data: {
  ...data,
  design: { ...data.design, notesCount: data.design.notesCount + 1 },
}});

Actually, this solution is not correct - we're not updating entire path. Updated nodes are highlighted in green:

graph LR
  design --> discussions
  discussions--> discussions.edges
  discussions.edges --> discussions.edges.node
  discussions.edges.node --> notes
  notes --> notes.edges

  classDef green fill:#9f6
  class design,notes.edges green

When we update to apollo-cache-inmemory@1.6.3 (see gitlab-org/gitlab#35898 (closed) for details) we will see the expected error: image

(Potentially) proper solution:

const data = store.readQuery({ /* ... */ });
const currentDiscussion = extractCurrentDiscussion(data.design.discussions, this.discussion.id);
const newDiscussion = {
  ...currentDiscussion,
  node: {
    ...currentDiscussion.node,
    notes: {
      ...currentDiscusion.notes,
      edges: [ 
        ...currentDiscussion.node.notes.edges,
        { __typename: 'NoteEdge', node: createNote.note },
      ]
    }
  }
};

const currentDiscussionIndex = data.design.discussions.edges.findIndex(currentDiscussion);
store.writeQuery({ query: getDesignQuery, data: {
  ...data,
  design: { 
    ...data.design, 
    discussions: {
      ...data.design.discussions,
      edges: [
        ...data.design.discussions.edges.slice(0, currentDiscussionIndex), 
        newDiscussion, 
        ...this.state.images.slice(currentDiscussionIndex)
      ]
    },
    notesCount: data.design.notesCount + 1 
  },
}});

Obviously, all these ... get out of control very quickly. Immer generates a draft object, based on source, allows us to mutate draft object, and automatically generates the new (immutable-compliant) version of our source based on updates. So, this code could rewritten as (produce is imported from immer):

const data = store.readQuery({ /* ... */ });
const newData = produce(data, draftData => {
  const currentDiscussion = extractCurrentDiscussion(draftData.design.discussions, this.discussion.id);
  currentDiscussion.node.notes.edges.push({ __typename: 'NoteEdge', node: createNote.note });
  draftData.design.notesCount += 1;
});
store.writeQuery({ query: getDesignQuery, newData });

Advantages of the new pattern

  1. Guaranteed safe immutability
  2. Speed - immer uses Proxy in newer browsers and silently downgrades to get/set in the old one.
  3. Cleaner "Vue-way" code. Vue (compared to React) encourages people to have mutable state (like in Vuex), so this pattern should be easier to consider for Vue developers

Disadvantages of the new pattern

  1. Additional library to learn and understand by new devs and to maintain
  2. The magic behind converting the draft state to the immutable object might be hard to debug & some corner cases theoretically possible
  3. Additional bundle size (~20kb minified, ~6kb gzipped)

What is the impact on our existing codebase?

Our codebase will not be immediately impacted - we will gradually introduce immer (or other solution) where needed. We can still make simple changes without immer (although I prefer consistency here)

Currently, we have 6 writeQuery calls and based on my quick view, only design management app will be affected

Other possible options

We can consider using another approach - for example using immutability-helper.

Version with immutability-helper (update imported from immutability-helper):

const data = store.readQuery({ /* ... */ });
const currentDiscussion = extractCurrentDiscussion(data.design.discussions, this.discussion.id);
const newDiscussion = update(currentDiscussion, { node: { notes: { edges: { $push: 
  { __typename: 'NoteEdge', node: createNote.note }
}}}});

const currentDiscussionIndex = data.design.discussions.edges.findIndex(currentDiscussion);
const newData = update(data, { design : {
  notesCount: { $set: data.design.notesCount + 1 },
  discussions: { edges: { [currentDiscussionIndex]: { $set: newDiscussion }}}
}});
store.writeQuery({ query: getDesignQuery, newData });

Pros:

  • no magic. It's imperative operations, easy to reason about
  • less bundle size (2x smaller than immer)

Cons:

  • need to learn new library syntax, way easier to unintentionally do a mutation
  • bigger API surface (immer will have just produce sufficient for us most of the time)
  • verbose

Let's vote for this:

  • 👍 - let's go with immer!
  • 👎 - let's keep maintaining immutable things by hand, as it's happening now
  • 💛 - let's use another library, with less magic (like immutability-helper or other alternatives, please provide your preference in comments if you have one)
Edited by Illya Klymov