React and Graphql Optimization Story
While doing some work on the Product Hunt stories page, I noticed that the page was performing too many SQL queries. The page itself is quite simple.
The following is the React structure of the page:
<Header>
<Flex.Row>
<Image>
<Flex.Column>
<Title />
<Meta />
<Tagline />
<StoryVoteButton showVoters={true} />
</Flex.Column>
</Flex.Row>
</Header>
<Layout>
<Layout.Content>
<StoryItem>
<Flex.Row>
<Flex.Column>
<Title />
<Meta />
<StoryVoteButton showVoters={false} />
<Tagline />
</Flex.Column>
<Image>
</Flex.Row>
</StoryItem>
</Layout.Content>
<Layout.Sidebar />
</Layout>
For the page we have the following GraphQL query:
#import "~/components/StoryHero/Fragment.graphql"
#import "~/components/StoryItem/Fragment.graphql"
query StoriesIndexPage($cursor: String) {
heroStory: stories(first: 1) {
edges {
node {
id
...StoryHeroFragment
}
}
}
stories(first: 8, after: $cursor) {
edges {
node {
id
...StoryItemFragment
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
After some debugging, I figure out the issue is the StoryVoteButton
. It was making N+1 queries to load the voters of story items.
The vote button was using the following GraphQL Fragment
# FILE: ~/components/StoryVoteButton/Fragment.graphql
#import "~/components/UserImage/Fragment.graphql"
fragment StoryVoteButtonFragment on Story {
id
hasVoted
votesCount
voters(first: 6) {
edges {
node {
id
...UserImage
}
}
}
}
Notice that it loads the voters of the story. It is quite expensive, because:
- the connection edge doesn't batch load voters due to pagination
- the voters query is expensive due to complex order conditions like showing current user friends first, then popular users, and so.
The button had two variants with and without voters:
Showing voters was controlled by a prop named showVoters
:
<StoryVoteButton story={story} showVoters={true} />
<StoryVoteButton story={story} showVoters={false} />
When showVoters
is false, we don't need voters
. However, we still load them. We only need them once on a page, so we over-fetch most of the time.
Fix 1
My first fix took me just 15 minutes.
I just created a second fragment, FragmentWithVoters.graphql
, and let the component consumers use it when they pass showVoters={true}
. This fragment included the voters.
# FILE: "~/components/StoryVoteButton/Fragment.graphql"
fragment StoryVoteButtonFragment on Story {
id
hasVoted
votesCount
}
# FILE: "~/components/StoryVoteButton/FragmentWithVoters.graphql"
#import "~/components/StoryVoteButton/Fragment.graphql"
#import "~/components/UserImage/Fragment.graphql"
fragment StoryVoteButtonWithVotersFragment on Story {
id
voters(first: 6) {
edges {
node {
id
...UserImage
}
}
}
...StoryVoteButtonFragment
}
It was nice and easy.
Job well done ... ?
...then, while I was doing a self-code-review, I realized that this makes the code more complex.
The consumer has to know that a property requires a particular fragment and use it instead of the default one. This distinction isn't obvious. ?
Those thoughts helped me realize and I had broken this button on the stories show page. ?
Fix 2
I moved my pull request into a "work in progress" state and started refactoring.
I decided to split StoryVoteButton
into:
StoryVoteButton
- loads only counters
StoryVoteButtonWithVoters
-StoryVoteButton
+ story voters
Conclusion
Moral of the story: Alway do a self-code-review as if you didn't write this code ?
Secondary learning is that in React, sometimes props are just hidden components, and we shouldn't push too much logic in a single component. Better to have two components. ?