Im using update after a mutation to update the store when a new comment is created. I also have a subscription for comments on this page.
Either one of these methods works as expected by itself. However when I have both, then the user who created the comment will see the comment on the page twice and get this error from React:
Warning: Encountered two children with the same key,
I think the reason for this is the mutation update and the subscription both return a new node, creating a duplicate entry. Is there a recommended solution to this? I couldn’t see anything in the Apollo docs but it doesn’t seem like that much of an edge use case to me.
This is the component with my subscription:
import React from 'react';
import { graphql, compose } from 'react-apollo';
import gql from 'graphql-tag';
import Comments from './Comments';
import NewComment from './NewComment';
import _cloneDeep from 'lodash/cloneDeep';
import Loading from '../Loading/Loading';
class CommentsEventContainer extends React.Component {
_subscribeToNewComments = () => {
this.props.COMMENTS.subscribeToMore({
variables: {
eventId: this.props.eventId,
},
document: gql`
subscription newPosts($eventId: ID!) {
Post(
filter: {
mutation_in: [CREATED]
node: { event: { id: $eventId } }
}
) {
node {
id
body
createdAt
event {
id
}
author {
id
}
}
}
}
`,
updateQuery: (previous, { subscriptionData }) => {
// Make vars from the new subscription data
const {
author,
body,
id,
__typename,
createdAt,
event,
} = subscriptionData.data.Post.node;
// Clone store
let newPosts = _cloneDeep(previous);
// Add sub data to cloned store
newPosts.allPosts.unshift({
author,
body,
id,
__typename,
createdAt,
event,
});
// Return new store obj
return newPosts;
},
});
};
_subscribeToNewReplies = () => {
this.props.COMMENT_REPLIES.subscribeToMore({
variables: {
eventId: this.props.eventId,
},
document: gql`
subscription newPostReplys($eventId: ID!) {
PostReply(
filter: {
mutation_in: [CREATED]
node: { replyTo: { event: { id: $eventId } } }
}
) {
node {
id
replyTo {
id
}
body
createdAt
author {
id
}
}
}
}
`,
updateQuery: (previous, { subscriptionData }) => {
// Make vars from the new subscription data
const {
author,
body,
id,
__typename,
createdAt,
replyTo,
} = subscriptionData.data.PostReply.node;
// Clone store
let newPostReplies = _cloneDeep(previous);
// Add sub data to cloned store
newPostReplies.allPostReplies.unshift({
author,
body,
id,
__typename,
createdAt,
replyTo,
});
// Return new store obj
return newPostReplies;
},
});
};
componentDidMount() {
this._subscribeToNewComments();
this._subscribeToNewReplies();
}
render() {
if (this.props.COMMENTS.loading || this.props.COMMENT_REPLIES.loading) {
return <Loading />;
}
const { eventId } = this.props;
const comments = this.props.COMMENTS.allPosts;
const replies = this.props.COMMENT_REPLIES.allPostReplies;
const { user } = this.props.COMMENTS;
const hideNewCommentForm = () => {
if (this.props.hideNewCommentForm === true) return true;
if (!user) return true;
return false;
};
return (
<React.Fragment>
{!hideNewCommentForm() && (
<NewComment
eventId={eventId}
groupOrEvent="event"
queryToUpdate={COMMENTS}
/>
)}
<Comments
comments={comments}
replies={replies}
queryToUpdate={{ COMMENT_REPLIES, eventId }}
hideNewCommentForm={hideNewCommentForm()}
/>
</React.Fragment>
);
}
}
const COMMENTS = gql`
query allPosts($eventId: ID!) {
user {
id
}
allPosts(filter: { event: { id: $eventId } }, orderBy: createdAt_DESC) {
id
body
createdAt
author {
id
}
event {
id
}
}
}
`;
const COMMENT_REPLIES = gql`
query allPostReplies($eventId: ID!) {
allPostReplies(
filter: { replyTo: { event: { id: $eventId } } }
orderBy: createdAt_DESC
) {
id
replyTo {
id
}
body
createdAt
author {
id
}
}
}
`;
const CommentsEventContainerExport = compose(
graphql(COMMENTS, {
name: 'COMMENTS',
}),
graphql(COMMENT_REPLIES, {
name: 'COMMENT_REPLIES',
}),
)(CommentsEventContainer);
export default CommentsEventContainerExport;
And here is the NewComment component:
import React from 'react';
import { compose, graphql } from 'react-apollo';
import gql from 'graphql-tag';
import './NewComment.css';
import UserPic from '../UserPic/UserPic';
import Loading from '../Loading/Loading';
class NewComment extends React.Component {
constructor(props) {
super(props);
this.state = {
body: '',
};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
}
handleChange(e) {
this.setState({ body: e.target.value });
}
onKeyDown(e) {
if (e.keyCode === 13) {
e.preventDefault();
this.handleSubmit();
}
}
handleSubmit(e) {
if (e !== undefined) {
e.preventDefault();
}
const { groupOrEvent } = this.props;
const authorId = this.props.USER.user.id;
const { body } = this.state;
const { queryToUpdate } = this.props;
const fakeId = '-' + Math.random().toString();
const fakeTime = new Date();
if (groupOrEvent === 'group') {
const { locationId, groupId } = this.props;
this.props.CREATE_GROUP_COMMENT({
variables: {
locationId,
groupId,
body,
authorId,
},
optimisticResponse: {
__typename: 'Mutation',
createPost: {
__typename: 'Post',
id: fakeId,
body,
createdAt: fakeTime,
reply: null,
event: null,
group: {
__typename: 'Group',
id: groupId,
},
location: {
__typename: 'Location',
id: locationId,
},
author: {
__typename: 'User',
id: authorId,
},
},
},
update: (proxy, { data: { createPost } }) => {
const data = proxy.readQuery({
query: queryToUpdate,
variables: {
groupId,
locationId,
},
});
data.allPosts.unshift(createPost);
proxy.writeQuery({
query: queryToUpdate,
variables: {
groupId,
locationId,
},
data,
});
},
});
} else if (groupOrEvent === 'event') {
const { eventId } = this.props;
this.props.CREATE_EVENT_COMMENT({
variables: {
eventId,
body,
authorId,
},
optimisticResponse: {
__typename: 'Mutation',
createPost: {
__typename: 'Post',
id: fakeId,
body,
createdAt: fakeTime,
reply: null,
event: {
__typename: 'Event',
id: eventId,
},
author: {
__typename: 'User',
id: authorId,
},
},
},
update: (proxy, { data: { createPost } }) => {
const data = proxy.readQuery({
query: queryToUpdate,
variables: { eventId },
});
data.allPosts.unshift(createPost);
proxy.writeQuery({
query: queryToUpdate,
variables: { eventId },
data,
});
},
});
}
this.setState({ body: '' });
}
render() {
if (this.props.USER.loading) return <Loading />;
return (
<form
onSubmit={this.handleSubmit}
className="NewComment NewComment--initial section section--padded"
>
<UserPic userId={this.props.USER.user.id} />
<textarea
value={this.state.body}
onChange={this.handleChange}
onKeyDown={this.onKeyDown}
rows="3"
/>
<button className="btnIcon" type="submit">
Submit
</button>
</form>
);
}
}
const USER = gql`
query USER {
user {
id
}
}
`;
const CREATE_GROUP_COMMENT = gql`
mutation CREATE_GROUP_COMMENT(
$body: String!
$authorId: ID!
$locationId: ID!
$groupId: ID!
) {
createPost(
body: $body
authorId: $authorId
locationId: $locationId
groupId: $groupId
) {
id
body
author {
id
}
createdAt
event {
id
}
group {
id
}
location {
id
}
reply {
id
replyTo {
id
}
}
}
}
`;
const CREATE_EVENT_COMMENT = gql`
mutation CREATE_EVENT_COMMENT($body: String!, $eventId: ID!, $authorId: ID!) {
createPost(body: $body, authorId: $authorId, eventId: $eventId) {
id
body
author {
id
}
createdAt
event {
id
}
}
}
`;
const NewCommentExport = compose(
graphql(CREATE_GROUP_COMMENT, {
name: 'CREATE_GROUP_COMMENT',
}),
graphql(CREATE_EVENT_COMMENT, {
name: 'CREATE_EVENT_COMMENT',
}),
graphql(USER, {
name: 'USER',
}),
)(NewComment);
export default NewCommentExport;
And the full error message is:
Warning: Encountered two children with the same key, `cjexujn8hkh5x0192cu27h94k`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.
in ul (at Comments.js:9)
in Comments (at CommentsEventContainer.js:157)
in CommentsEventContainer (created by Apollo(CommentsEventContainer))
in Apollo(CommentsEventContainer) (created by Apollo(Apollo(CommentsEventContainer)))
in Apollo(Apollo(CommentsEventContainer)) (at EventPage.js:110)
in section (at EventPage.js:109)
in DocumentTitle (created by SideEffect(DocumentTitle))
in SideEffect(DocumentTitle) (at EventPage.js:51)
in EventPage (created by Apollo(EventPage))
in Apollo(EventPage) (at App.js:176)
in Route (at App.js:171)
in Switch (at App.js:94)
in div (at App.js:93)
in main (at App.js:80)
in Router (created by BrowserRouter)
in BrowserRouter (at App.js:72)
in App (created by Apollo(App))
in Apollo(App) (at index.js:90)
in QueryRecyclerProvider (created by ApolloProvider)
in ApolloProvider (at index.js:89)
If a mutation updates a single existing entity, Apollo Client can automatically update that entity's value in its cache when the mutation returns. To do so, the mutation must return the id of the modified entity, along with the values of the fields that were modified.
Update mutations take filter as an input to select specific objects. You can specify set and remove operations on fields belonging to the filtered objects. It returns the state of the objects after updating. Note Executing an empty remove {} or an empty set{} doesn't have any effect on the update mutation.
Subscriptions are usually implemented with WebSockets, where the server holds a steady connection to the client. This means when working with subscriptions, we're breaking the Request-Response cycle that is typically used for interactions with the API.
This is actually pretty easy to fix. I was confused for a long time as my subscriptions would intermittently fail. It turns out this was a Graphcool issue, switching from the Asian to the USA cluster stoped the flakiness.
You just have to test to see if the ID already exists in the store, and not add it if it does. Ive added code comments where I've changed the code:
_subscribeToNewComments = () => {
this.props.COMMENTS.subscribeToMore({
variables: {
eventId: this.props.eventId,
},
document: gql`
subscription newPosts($eventId: ID!) {
Post(
filter: {
mutation_in: [CREATED]
node: { event: { id: $eventId } }
}
) {
node {
id
body
createdAt
event {
id
}
author {
id
}
}
}
}
`,
updateQuery: (previous, { subscriptionData }) => {
const {
author,
body,
id,
__typename,
createdAt,
event,
} = subscriptionData.data.Post.node;
let newPosts = _cloneDeep(previous);
// Test to see if item is already in the store
const idAlreadyExists =
newPosts.allPosts.filter(item => {
return item.id === id;
}).length > 0;
// Only add it if it isn't already there
if (!idAlreadyExists) {
newPosts.allPosts.unshift({
author,
body,
id,
__typename,
createdAt,
event,
});
return newPosts;
}
},
});
};
_subscribeToNewReplies = () => {
this.props.COMMENT_REPLIES.subscribeToMore({
variables: {
eventId: this.props.eventId,
},
document: gql`
subscription newPostReplys($eventId: ID!) {
PostReply(
filter: {
mutation_in: [CREATED]
node: { replyTo: { event: { id: $eventId } } }
}
) {
node {
id
replyTo {
id
}
body
createdAt
author {
id
}
}
}
}
`,
updateQuery: (previous, { subscriptionData }) => {
const {
author,
body,
id,
__typename,
createdAt,
replyTo,
} = subscriptionData.data.PostReply.node;
let newPostReplies = _cloneDeep(previous);
// Test to see if item is already in the store
const idAlreadyExists =
newPostReplies.allPostReplies.filter(item => {
return item.id === id;
}).length > 0;
// Only add it if it isn't already there
if (!idAlreadyExists) {
newPostReplies.allPostReplies.unshift({
author,
body,
id,
__typename,
createdAt,
replyTo,
});
return newPostReplies;
}
},
});
};
I stumbled upon the same problem and did not find an easy and clean solution.
What i did was using the filter functionality of the subscription resolver on the server. You can follow this tutorial which describes how to set up the server and this tutorial for the client.
In short:
type Query {
getBrowserSessionId: ID!
}
Query: {
getBrowserSessionId() {
return 1; // some uuid
},
}
...
if (!getBrowserSessionIdQuery.loading) {
localStorage.setItem("browserSessionId", getBrowserSessionIdQuery.getBrowserSessionId);
}
...
const getBrowserSessionIdQueryDefinition = gql`
query getBrowserSessionId {
getBrowserSessionId
}
`;
const getBrowserSessionIdQuery = graphql(getBrowserSessionIdQueryDefinition, {
name: "getBrowserSessionIdQuery"
});
...
type Subscription {
messageAdded(browserSessionId: ID!): Message
}
import { withFilter } from ‘graphql-subscriptions’;
...
Subscription: {
messageAdded: {
subscribe: withFilter(
() => pubsub.asyncIterator(‘messageAdded’),
(payload, variables) => {
// do not update the browser with the same sessionId with which the mutation is performed
return payload.browserSessionId !== variables.browserSessionId;
}
)
}
}
...
const messageSubscription= gql`
subscription messageAdded($browserSessionId: ID!) {
messageAdded(browserSessionId: $browserSessionId) {
// data from message
}
}
`
...
componentWillMount() {
this.props.data.subscribeToMore({
document: messagesSubscription,
variables: {
browserSessionId: localStorage.getItem("browserSessionId"),
},
updateQuery: (prev, {subscriptionData}) => {
// update the query
}
});
}
`Mutation {
createMessage(message: MessageInput!, browserSessionId: ID!): Message!
}`
...
createMessage: (_, { message, browserSessionId }) => {
const newMessage ...
...
pubsub.publish(‘messageAdded’, {
messageAdded: newMessage,
browserSessionId
});
return newMessage;
}
const createMessageMutation = gql`
mutation createMessage($message: MessageInput!, $browserSessionId: ID!) {
createMessage(message: $message, browserSessionId: $browserSessionId) {
...
}
}
`
...
graphql(createMessageMutation, {
props: ({ mutate }) => ({
createMessage: (message, browserSessionId) => {
return mutate({
variables: {
message,
browserSessionId,
},
update: ...,
});
},
}),
});
...
_onSubmit = (message) => {
const browserSessionId = localStorage.getItem("browserSessionId");
this.props.createMessage(message, browserSessionId);
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With