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) to support new pattern: for example gitlab-org/gitlab@455a60db via gitlab-org/gitlab!19536 (merged)
immer
for safe immutability in GraphQL-enabled apps
New Pattern Proposal: Use 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:
(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
- Guaranteed safe immutability
- Speed -
immer
uses Proxy in newer browsers and silently downgrades toget/set
in the old one. - 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
- Additional library to learn and understand by new devs and to maintain
- The magic behind converting the draft state to the immutable object might be hard to debug & some corner cases theoretically possible
- 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 justproduce
sufficient for us most of the time) - verbose
Let's vote for this:
-
👍 - let's go withimmer
! -
👎 - let's keep maintaining immutable things by hand, as it's happening now -
💛 - let's use another library, with less magic (likeimmutability-helper
or other alternatives, please provide your preference in comments if you have one)