GraphQL: Understanding, Implementing, and Best Practices

Think-it logo
Web Chapter
Engineering.11 min read
A step-by-step understanding around GraphQL best practices.

GraphQL offers a more flexible and efficient approach compared to traditional RESTful services. By enabling clients to request precisely the data they need, GraphQL significantly improves API efficiency and reduces over-fetching, leading to better performance and user experience.

At its core, GraphQL is a query language for your API, allowing clients to request exactly the data they need and nothing more. This selective data retrieval reduces over-fetching and under-fetching issues common in REST APIs.

While Apollo GraphQL’s official documentation explains how to setup both Apollo Client and Server to send and handle requests at https://www.apollographql.com/docs/, this blog post will explore setting up Apollo with React, some best practices for state management and caching, the benefits of using fragments, and some common pitfalls to avoid.

How GraphQL Works

A GraphQL server exposes a single endpoint and utilizes a schema to define the types of data that can be queried or mutated. Clients send queries or mutations to this endpoint, and the server resolves these requests based on the schema, often aggregating data from multiple sources.

  • Schema Definition: The schema is the backbone of a GraphQL API, defining the types, queries, and mutations available. It serves as a contract between the client and the server.
  • Resolvers: Resolvers are functions that handle the logic for fetching the data defined in the schema. Each field in a GraphQL query corresponds to a resolver function.
  • Query Execution: When a query is executed, the GraphQL engine parses it, validates it against the schema, and calls the appropriate resolvers to fetch the requested data.
  • Performance Optimization: GraphQL allows for efficient data fetching by enabling clients to request multiple resources in a single query, reducing network overhead and improving application performance

Apollo GraphQL Setup

Apollo Client is a powerful tool for managing GraphQL operations on the client-side. It provides a robust set of features for state management, caching, and error handling, which are crucial for building scalable and efficient GraphQL applications.

As effective state management and caching are critical for building performant GraphQL applications. Apollo Client excels in these areas with several built-in features:

  • Normalized Caching: Apollo Client normalizes the data it receives from the server, storing it in a flat structure. This allows for efficient data retrieval and minimizes redundancy.
  • Local State Management: Apollo Client can manage both remote and local state, allowing you to leverage GraphQL for application-wide state management.
// src/apollo-client.ts
import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: '<https://your-graphql-endpoint.com/graphql>',
  cache: new InMemoryCache(),
});

export default client;

Create Apollo Client

// src/App.tsx
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import client from './apollo-client';
import YourComponent from './YourComponent';

const App: React.FC = () => (
  <ApolloProvider client={client}>
    <YourComponent />
  </ApolloProvider>
);

export default App;

Using Apollo Client in Components

// src/YourComponent.tsx
import React from 'react';
import { useQuery, gql } from '@apollo/client';

const GET_DATA = gql`
  query GetData {
    data {
      id
      name
    }
  }
`;

interface Data {
  data: {
    id: string;
    name: string;
  }[];
}

const YourComponent: React.FC = () => {
  const { loading, error, data } = useQuery<Data>(GET_DATA);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      {data?.data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
};

export default YourComponent;

Query Example with TypeScript

Apollo Client seamlessly integrates with React's component model and hooks, making it a natural fit for managing server state in React applications.

Good Practices

In this section, we will go through using query params to filter the results of our queries, how to use fragments to improve the way queries and mutations can be organized, and how to leverage cache to lazy-load nested fields for better performance and less loading times (better UX).

Implementing these best practices not only improves code organization but also enhances the overall efficiency of your GraphQL implementation. By optimizing queries and leveraging caching strategies, you can significantly reduce latency and improve application performance.

export const groupAdminQuery = gql`
  query user(
	  $username: String!
    $category: ID
    $onlyVerified: Boolean
  ) {
      _id
      firstName
      lastName
      subscriptions (
        onlyVerified: $onlyVerified
        category: $category
      ) {
        _id
        title
        status
        progress
      }
  }
`;

Query Params

subscriptions here, is a nested field that we expect when fetching the user.

we pass the parameters needed when requesting the user of a specific username, when using the provided useQuery hook as second argument.

const {
    data,
    loading,
    error,
  } = useQuery(groupAdminQuery, {
    variables: {
	    username: username
      category: categoryId,
      onlyVerified: true,
    },
  });

The username is the only required parameter here, but we will be fetching a certain user, with his subscriptions. In this case, we will also only search for the list of subscriptions that are verified and belong to a certain category.

Using Fragments

Fragments in GraphQL are reusable units of logic that can be shared across multiple queries and mutations. They offer several benefits:

  • Reusability: Define common fields in a fragment and use them across different queries or mutations.
  • Maintainability: Update the fragment in one place, and all queries/mutations using it will reflect the change.
  • Optimized Queries: Fragments help in reducing query complexity and ensure consistent data fetching patterns.
import { gql } from '@apollo/client';

const ITEM_FRAGMENT = gql`
fragment ItemFragment on Item {
	id
	name
}
`
;
const GET_ITEMS = gql`
query GetItems {
	items {
		...ItemFragment
	}
}
${ITEM_FRAGMENT}
`
;
const ADD_ITEM = gql`
mutation AddItem($name: String!) {
	addItem(name: $name) {
		...ItemFragment
	}
}
${ITEM_FRAGMENT}
`
;

Example of Cache Update:

As we noticed in the query params section, nested fields need to hit the database several times, so naturally the performance would suffer if we overuse them.

So a workaround is to first only load the data required for the page needed.

/**
 * retrieves the user's identity information.
 */
export const meQuery = gql`
  query MeQuery {
    me {
      _id
      isAdmin
      identifyInfo {
	      ...IdentifyInfoFragment
      }
      subscriptions {
	      ...SubscriptionsFragment
      }
      notifications {
	      ...NotificationsFragment
      }
    }
  }
`;

Let’s say that in the welcome page, we never see the subscriptions. Or we want a separate loading time for the UI component that would show them. This would allow the user to interact with the rest of the page. So it makes sense to not fetch them in the beginning as this will only make the loading time longer for data that is more essential to user interaction.

/**
 * retrieves the user's identity information.
 */
export const meQuery = gql`
  query MeQuery {
    me {
      _id
      isAdmin
      identityInfo {
        username
        firstName
        lastName
        birthDate
        gender
        email
        picture
      }
    }
  }
`;

/**
 * retrieves the user's notifications.
 */
export const userSubscriptions = gql`
  query userSubscriptions {
    mySubscriptions: me {
      _id
      subscriptions {
        _id
        status
        title
      }
    }
  }
`;

One way to only call userSubscriptions when meQuery is done, is to call them separately, but ask graphql to wait using skip option set to true if the first fetching is still loading, and then merge them into the same object.

PS: Notice that in the example above, we mention the me field right after “myNotifications”. This way GraphQL knows that we are fetching data that belongs to the same object

const {
    data,
    loading,
    error,
  } = useQuery(meQuery);
  
  const {
    data: subscriptionsData,
    loading: subscriptionsLoading,
    error: subscriptionsError,
  } = useQuery(userSubscriptions, {
	   skip: loading,
  });

Below, when defining the InMemoryCache for ApolloClient, we specify which fields we want to merge with the already cached data when fetching.

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
	      // object example
	      me: {
          merge(existing, incoming, { mergeObjects }) {
            return mergeObjects(existing, incoming);
          },
        },
        // array example
        tasks: {
          merge(existing = [], incoming: any[]) {
            return [...existing, ...incoming];
          },
        },
      },
    },
  },
});

The me example is for loading extra parameters into an already fetched object.

And the tasks is for merging into an array. Which is useful for pagination or loading on scroll for example.

Conclusion

While GraphQL offers numerous advantages, there are pitfalls to watch out for:

  • Over-fetching with Nested Queries: It’s easy to create deeply nested queries that fetch large amounts of data. This can lead to performance issues. Always be mindful of the depth and breadth of your queries.
  • Handling Errors: GraphQL doesn’t natively handle HTTP status codes like REST. Ensure you implement robust error handling in your client and server to manage GraphQL-specific errors properly.
  • Rate Limiting: Without proper rate limiting, GraphQL servers can be susceptible to Denial of Service (DoS) attacks. Implement rate limiting on your server to prevent abuse.
  • Schema Bloat: As your GraphQL API grows, it can become bloated with many types and fields. Regularly review and refactor your schema to keep it maintainable (naming conventions and can be very helpful here).
  • N+1 Query Problem: This occurs when querying for nested resources, leading to multiple database requests. Use tools like DataLoader to batch and cache database requests efficiently.

Despite these challenges, GraphQL remains a powerful tool for API development when implemented correctly. By following best practices and staying aware of potential pitfalls, developers can leverage GraphQL to create efficient, flexible, and performant applications that meet the evolving needs of modern software development.

Share this story