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:
- Perform login mutation to get auth token from server
- Store auth token in memory (or local storage for persistence)
- 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 arecache-first
(default),cache-and-network
,network-only
,cache-only
, andno-cache
. - Modify the cache directly – Using
readQuery
,writeQuery
,readFragment
,writeFragment
, andmodify
- 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 theApolloClient
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!