/ React

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.

Product Hunt Stories

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:

Vote button with voters Vote botton 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:

  1. StoryVoteButton - loads only counters
Vote botton without voters
  1. StoryVoteButtonWithVoters - StoryVoteButton + story voters
Vote button with 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. 🎩