Supercharging Your Redux App with GraphQL

If you‘ve worked on a large Redux application, you‘re probably familiar with some of the pain points – lots of boilerplate for defining actions and reducers, manually normalizing server responses, and overfetching data from REST APIs. GraphQL offers a compelling alternative that can simplify data fetching in your Redux app. In this post, we‘ll explore what GraphQL is, how it can benefit a Redux architecture, and walk through a detailed example of integrating it into an existing Redux app.

What is GraphQL?

GraphQL is a query language and runtime for APIs, developed internally by Facebook in 2012 before being publicly open sourced in 2015. It allows clients to define the structure of the data they need, and the server returns exactly that structure, no more no less.

Some key features of GraphQL:

  • Typed schema – The schema serves as a contract between client and server, defining what queries are available, what fields can be requested, and what format the data will be returned in.
  • Hierarchical – Queries mirror the shape of your data and the relationships between objects. You can request related objects in a single round trip.
  • Compositional – GraphQL is agnostic to network protocols, allowing seamless stitching of disparate backends and services into a unified API.

This is in contrast to a traditional REST API, where the server defines a fixed structure for each endpoint. Fetching data for a particular view often requires hitting multiple endpoints and stitching the responses together on the client. Over or under fetching of data is common since the response structure is fixed. With GraphQL, the client is in full control over the data it gets back.

Why Use GraphQL with Redux

Redux offers a simple, predictable state container where the data for your whole application lives in a single store. But it intentionally says nothing about how that data gets into the store in the first place. A common pattern is to use action creators to fetch data from a REST API, then dispatch the response to reducers which update the store.

There are a few issues with this approach in large apps:

  • Boilerplate – Each entity generally needs its own actions, action creators, and reducer logic for fetching and updating. This can quickly bloat your codebase.
  • Overfetching – Redux encourages a normalized state shape where each entity type is stored in a separate top-level slice. But a normalized REST response often includes more data than is needed for a particular view.
  • Waterfall requests – Rendering a view often requires data from multiple endpoints. But without knowing the relationships between entities, you end up needing to waterfall requests based on IDs returned from previous ones.

Swapping a REST API for GraphQL eliminates a lot of this complexity:

  • Less boilerplate – No need for action creators or reducers to manage data fetching and caching. A single GraphQL query or mutation can replace multiple actions.
  • Only fetch what you need – Each component specifies exactly the fields it needs. No more, no less.
  • Colocate data dependencies – Components declare their data dependencies right alongside their render logic. No need to trace the flow of data through disparate action creators and reducers.
  • Build views faster – With GraphQL, you can start building UI components immediately and iterate on their data requirements later. No need to coordinate across teams to deploy additional endpoints.

Adding GraphQL to a Redux App

Enough theory, let‘s see how to actually integrate GraphQL into a Redux app. We‘ll use Apollo Client, the most popular GraphQL client, though other options like Relay are available.

Setting up Apollo Client

First, install the necessary dependencies:

npm install @apollo/client graphql

Next, create an ApolloClient instance and wrap your root component with the ApolloProvider:

import { ApolloClient, InMemoryCache, ApolloProvider } from ‘@apollo/client‘;

const client = new ApolloClient({
  uri: ‘https://your-graphql-endpoint‘,
  cache: new InMemoryCache(),
});

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById(‘root‘)
);

The ApolloProvider is similar to Redux‘s Provider component. It takes a client prop and makes that client available to all child components via React context.

Defining Queries and Mutations

Before we can fetch any data, we need to define our GraphQL queries and mutations. With Apollo, you do this by annotating your components with a gql tag. For example:

import { gql } from ‘@apollo/client‘;

const GET_LAUNCHES = gql`
  query GetLaunchList($after: String) {
    launches(after: $after) {
      cursor
      hasMore
      launches {
        id
        isBooked
        rocket {
          id
          name
        }
        mission {
          name
          missionPatch
        }
      }
    }
  }
`;

This defines a query called GET_LAUNCHES that takes a variable $after for pagination. It returns a list of launches with a cursor for fetching subsequent pages, a flag indicating if there are more launches, and fields for id, isBooked status, associated rocket, and mission details. We‘ll see how to pass variables and use the returned cursor shortly.

Similarly, you can define mutations for updating data:

const BOOK_TRIP = gql`
  mutation BookTrips($launchIds: [ID]!) {
    bookTrips(launchIds: $launchIds) {
      success
      message
      launches {
        id
        isBooked
      }
    }
  }
`;

The BOOK_TRIP mutation allows you to pass in an array of launch IDs and book them all in one shot. It returns a success status, message, and the updated launch objects.

Executing Queries

Now that we have a query defined, let‘s use it to fetch some data in a component. Apollo provides a useQuery hook for executing queries and managing their lifecycle:

import { useQuery } from ‘@apollo/client‘;

function LaunchList() {
  const { 
    data, 
    loading, 
    error, 
    fetchMore,
  } = useQuery(GET_LAUNCHES);

  if (loading) return <Loading />;
  if (error) return <p>ERROR</p>;

  return (
    <>
      {data.launches.launches.map((launch) => (
        <LaunchTile key={launch.id} launch={launch} />
      ))}
      {data.launches.hasMore && (
        <button onClick={() => 
          fetchMore({
            variables: {
              after: data.launches.cursor,
            },
          })
        }>Load More</button>
      )}
    </>
  );
}

Here we pass our GET_LAUNCHES query to useQuery and destructure the returned values:

  • data – The actual data returned from the server. It‘s initially undefined until the response comes back.
  • loading – A boolean indicating if the request is still in flight. Useful for showing a loading spinner.
  • error – Any error returned from the server. Useful for displaying error messages.
  • fetchMore – A function for fetching additional results. We‘ll use it to implement pagination.

We first check the loading and error states to return appropriate feedback. Once we have data, we map over the list of launches and render a LaunchTile component for each. Finally, if there are more launches available, we show a button to load the next page, passing the end cursor to the fetchMore function.

By default Apollo Client caches query results, so calling fetchMore will merge the new results with the existing data in the cache rather than fully replacing it.

Executing Mutations

We can execute mutations with the useMutation hook, which has a similar signature to useQuery:

import { useMutation } from ‘@apollo/client‘;

function BookTripForm({ launchIds }) {
  const [bookTrips, { data, loading, error }] = useMutation(BOOK_TRIP, {
    variables: { launchIds },
  });

  return (
    <form onSubmit={bookTrips}>
      <button type="submit">Book All</button>
      {data && data.bookTrips.success && <p>Trips booked! 🥳</p>}
      {error && <p>ERROR: {error.message}</p>}
    </form>
  );
}

The useMutation hook returns a tuple with a function to trigger the mutation and an object with the mutation result. We immediately call this function in the onSubmit handler of the form, passing the launchIds as a variable. Once the mutation completes, we either show a success message or an error.

Advanced Features & Best Practices

We‘ve covered the basics of executing queries and mutations, but Apollo Client has a lot more to offer. Here are some additional features and best practices to consider.

Optimistic UI

By default Apollo updates the UI only after a mutation fully completes. This can feel sluggish for simple mutations like toggling a button. With optimistic UI, we can update the UI immediately and then roll it back if the mutation fails.

Here‘s how you might implement optimistic UI for the BOOK_TRIP mutation:

const [bookTrips, { data, loading, error }] = useMutation(
  BOOK_TRIP,
  {
    variables: { launchIds },
    optimisticResponse: {
      bookTrips: {
        __typename: ‘TripUpdateResponse‘,
        success: true,
        message: ‘Trips booked!‘,
        launches: launchIds.map((id) => ({
          __typename: ‘Launch‘,
          id,
          isBooked: true,
        })),
      },
    },
  }
);

The optimisticResponse option allows you to provide a mock response that will be used to update the cache immediately. It should match the shape of the actual mutation response. As soon as the button is clicked, you‘ll see the launches flip to a "Booked" state. If the mutation fails, Apollo will rewind the cache to its previous state.

Authentication

Apollo Client works with any authentication system. You just need to provide a way to attach auth tokens (or cookies) to outgoing requests. A typical flow looks like:

  1. Perform login mutation to get auth token from server
  2. Store auth token in memory (or local storage for persistence)
  3. Add auth token to the Authorization header of each request

You can modify headers by passing an options function to the ApolloClient constructor:

const client = new ApolloClient({
  uri: ‘https://your-graphql-endpoint‘,
  cache: new InMemoryCache(),
  request: (operation) => {
    const token = localStorage.getItem(‘token‘);
    operation.setContext({
      headers: {
        authorization: token ? `Bearer ${token}` : ‘‘
      }
    });
  },
});  

Make sure to also clear the token on logout.

Pagination

There are a number of different pagination strategies supported by GraphQL. The most common are:

  • Offset/limit – Request a slice of results by providing a starting offset and limit
  • Cursor-based – Each result includes a unique cursor that points to its position in the overall list
  • Page-based – Results are split into fixed-size pages. Navigation is done by passing a page number.

We saw an example of cursor-based pagination in the GET_LAUNCHES query earlier. Here‘s an example using offset/limit:

const GET_LAUNCHES = gql`
  query GetLaunchList($offset: Int, $limit: Int) {
    launches(offset: $offset, limit: $limit) {
      id
      # ...
    }
  }
`;

function LaunchList() {
  const { 
    data, 
    fetchMore,
  } = useQuery(GET_LAUNCHES, {
    variables: {
      offset: 0,
      limit: 10,
    },
  });

  // ...

  return (
    <>
      {/* render launches... */}
      <button onClick={() => 
        fetchMore({
          variables: {
            offset: data.launches.length,
            limit: 10,
          },
        })
      }>Load More</button>
    </>
  );
}

Apollo will automatically merge the results of each fetchMore query into a single list, so you can keep clicking "Load More" and it will keep appending results.

Caching

Apollo maintains a client-side cache of query results to minimize network requests. The cache is keyed based on the query name and variables. So executing the exact same query twice will return cached data for the second request.

You can configure the cache in a few key ways:

  • Specify fetchPolicy for individual queries – Options are cache-first (default), cache-and-network, network-only, cache-only, and no-cache.
  • Modify the cache directly – Using readQuery, writeQuery, readFragment, writeFragment, and modify
  • Specify fields to read or merge after a mutation – Using the update option in a mutation
  • Set global default options – Via the defaultOptions key in the ApolloClient constructor

See the Caching guide in the Apollo docs for more details.

Next Steps

That covers all the key concepts you need to start using GraphQL in your Redux app! Beyond what we‘ve looked at, here are a few more topics to explore:

  • Subscriptions for real-time data
  • Prefetching data based on routes
  • Testing GraphQL queries and mutations
  • Generating types from your schema for type safety
  • Local state management with Apollo (client-only fields and mutations)

I‘d also recommend diving into the Apollo docs for a more detailed look at the API and community best practices.

Have questions or feedback? Let me know in the comments! I‘m always excited to geek out about GraphQL. 😄 Happy querying!

Similar Posts