GraphQL
Building a GraphQL Client App
Building a GraphQL Client App
In this lesson, we'll build a complete React client application that consumes our GraphQL API. We'll use Apollo Client for state management, caching, and real-time updates.
Project Setup with Apollo Client
# Create React app
npx create-react-app blog-client
cd blog-client
# Install Apollo Client dependencies
npm install @apollo/client graphql
npm install react-router-dom
npm install @apollo/client-react-hooks
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
createHttpLink,
split,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
import App from './App';
// HTTP link for queries and mutations
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});
// WebSocket link for subscriptions
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
})
);
// Auth link to add JWT token
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
// Split links based on operation type
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
authLink.concat(httpLink)
);
// Create Apollo Client
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<ApolloProvider client={client}>
<BrowserRouter>
<App />
</BrowserRouter>
</ApolloProvider>
);
Apollo Client Setup: The client is configured with HTTP link for queries/mutations, WebSocket link for subscriptions, authentication middleware, and intelligent caching with type policies.
GraphQL Queries and Mutations
// src/graphql/queries.js
import { gql } from '@apollo/client';
export const GET_POSTS = gql`
query GetPosts($limit: Int, $offset: Int, $status: PostStatus) {
posts(limit: $limit, offset: $offset, status: $status) {
id
title
slug
excerpt
featuredImage
status
viewCount
createdAt
author {
id
username
avatar
}
}
}
`;
export const GET_POST = gql`
query GetPost($slug: String!) {
post(slug: $slug) {
id
title
slug
content
excerpt
featuredImage
status
viewCount
tags
createdAt
updatedAt
publishedAt
author {
id
username
fullName
avatar
bio
}
comments {
id
content
isEdited
createdAt
author {
id
username
avatar
}
replies {
id
content
createdAt
author {
id
username
avatar
}
}
}
}
}
`;
export const GET_ME = gql`
query GetMe {
me {
id
username
email
fullName
bio
avatar
role
}
}
`;
// src/graphql/mutations.js
import { gql } from '@apollo/client';
export const LOGIN = gql`
mutation Login($input: LoginInput!) {
login(input: $input) {
token
user {
id
username
email
role
}
}
}
`;
export const REGISTER = gql`
mutation Register($input: RegisterInput!) {
register(input: $input) {
token
user {
id
username
email
role
}
}
}
`;
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
status
createdAt
}
}
`;
export const UPDATE_POST = gql`
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
id
title
content
status
updatedAt
}
}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id)
}
`;
export const CREATE_COMMENT = gql`
mutation CreateComment($input: CreateCommentInput!) {
createComment(input: $input) {
id
content
createdAt
author {
id
username
avatar
}
}
}
`;
Querying Data in Components
// src/components/PostList.jsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_POSTS } from '../graphql/queries';
import { Link } from 'react-router-dom';
function PostList() {
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: { limit: 10, offset: 0, status: 'PUBLISHED' },
});
if (loading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
const loadMore = () => {
fetchMore({
variables: {
offset: data.posts.length,
},
});
};
return (
<div className="post-list">
<h1>Blog Posts</h1>
{data.posts.map(post => (
<article key={post.id} className="post-card">
{post.featuredImage && (
<img src={post.featuredImage} alt={post.title} />
)}
<h2>
<Link to={`/posts/${post.slug}`}>{post.title}</Link>
</h2>
<p>{post.excerpt}</p>
<div className="post-meta">
<span>By {post.author.username}</span>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
<span>{post.viewCount} views</span>
</div>
</article>
))}
<button onClick={loadMore}>Load More</button>
</div>
);
}
export default PostList;
Creating and Updating Data
// src/components/CreatePost.jsx
import React, { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_POST } from '../graphql/mutations';
import { GET_POSTS } from '../graphql/queries';
import { useNavigate } from 'react-router-dom';
function CreatePost() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
content: '',
excerpt: '',
tags: '',
});
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
onCompleted: (data) => {
navigate(`/posts/${data.createPost.slug}`);
},
refetchQueries: [{ query: GET_POSTS }],
});
const handleSubmit = async (e) => {
e.preventDefault();
const tags = formData.tags.split(',').map(tag => tag.trim());
await createPost({
variables: {
input: {
title: formData.title,
content: formData.content,
excerpt: formData.excerpt,
tags,
status: 'DRAFT',
},
},
});
};
return (
<form onSubmit={handleSubmit}>
<h2>Create New Post</h2>
<input
type="text"
placeholder="Title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
<textarea
placeholder="Excerpt"
value={formData.excerpt}
onChange={(e) => setFormData({ ...formData, excerpt: e.target.value })}
/>
<textarea
placeholder="Content (Markdown)"
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
rows={15}
required
/>
<input
type="text"
placeholder="Tags (comma separated)"
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
{error && <div className="error">{error.message}</div>}
</form>
);
}
export default CreatePost;
Cache Updates: Use `refetchQueries` to automatically update the cache after mutations, or manually update the cache with `update` function for optimistic UI updates.
Real-Time Updates with Subscriptions
// src/graphql/subscriptions.js
import { gql } from '@apollo/client';
export const POST_CREATED_SUBSCRIPTION = gql`
subscription OnPostCreated {
postCreated {
id
title
slug
excerpt
author {
username
}
}
}
`;
export const COMMENT_ADDED_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
createdAt
author {
id
username
avatar
}
}
}
`;
// src/components/PostDetail.jsx
import React, { useEffect } from 'react';
import { useQuery, useSubscription } from '@apollo/client';
import { useParams } from 'react-router-dom';
import { GET_POST } from '../graphql/queries';
import { COMMENT_ADDED_SUBSCRIPTION } from '../graphql/subscriptions';
function PostDetail() {
const { slug } = useParams();
const { loading, error, data } = useQuery(GET_POST, {
variables: { slug },
});
// Subscribe to new comments
useSubscription(COMMENT_ADDED_SUBSCRIPTION, {
variables: { postId: data?.post?.id },
skip: !data?.post?.id,
onData: ({ client, data: subData }) => {
// Update cache with new comment
const newComment = subData.data.commentAdded;
client.cache.modify({
id: client.cache.identify(data.post),
fields: {
comments(existingComments = []) {
return [...existingComments, newComment];
},
},
});
},
});
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
const { post } = data;
return (
<article className="post-detail">
<h1>{post.title}</h1>
{post.featuredImage && (
<img src={post.featuredImage} alt={post.title} />
)}
<div className="post-meta">
<span>By {post.author.username}</span>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
<span>{post.viewCount} views</span>
</div>
<div className="post-content" dangerouslySetInnerHTML={{ __html: post.content }} />
<div className="post-tags">
{post.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
<CommentsSection comments={post.comments} postId={post.id} />
</article>
);
}
export default PostDetail;
Error Handling and Loading States
// src/components/ErrorBoundary.jsx
import React from 'react';
import { useQuery } from '@apollo/client';
function ErrorDisplay({ error }) {
if (error.networkError) {
return (
<div className="error-network">
<h3>Network Error</h3>
<p>Unable to connect to the server. Please check your connection.</p>
</div>
);
}
if (error.graphQLErrors) {
return (
<div className="error-graphql">
<h3>Error</h3>
{error.graphQLErrors.map((err, i) => (
<p key={i}>{err.message}</p>
))}
</div>
);
}
return (
<div className="error-unknown">
<h3>An error occurred</h3>
<p>{error.message}</p>
</div>
);
}
// Loading component
function LoadingSpinner() {
return (
<div className="loading-spinner">
<div className="spinner"></div>
<p>Loading...</p>
</div>
);
}
export { ErrorDisplay, LoadingSpinner };
Client-Side Caching Strategy
// Optimistic UI updates
const [deletePost] = useMutation(DELETE_POST, {
optimisticResponse: {
deletePost: true,
},
update: (cache, { data }) => {
if (data.deletePost) {
cache.modify({
fields: {
posts(existingPosts = [], { readField }) {
return existingPosts.filter(
postRef => readField('id', postRef) !== postId
);
},
},
});
}
},
});
// Cache persistence
import { persistCache } from 'apollo3-cache-persist';
const cache = new InMemoryCache();
await persistCache({
cache,
storage: window.localStorage,
});
Cache Consistency: Always invalidate or update cache after mutations to keep UI in sync. Use optimistic responses for instant user feedback before server confirmation.
Practice Exercise:
- Implement authentication flow (login/register/logout)
- Create a comment form with real-time updates
- Add infinite scroll pagination for posts
- Implement file upload for featured images
- Add error handling and retry logic
- Test real-time subscriptions with multiple browser tabs
Production Ready: Your React + Apollo Client app now has complete GraphQL integration with queries, mutations, subscriptions, caching, and error handling. Ready to deploy!