Web DevelopmentNổi bật
GraphQL with Apollo Client
Ngo Van I
Full Stack Developer
4 tháng 1, 202504-01
16p
1,423 lượt xem1k
#GraphQL#Apollo Client#React#TypeScript
GraphQL with Apollo Client
Xây dựng efficient data layer với GraphQL và Apollo Client.
Apollo Client Setup
TypeScript
// apollo/client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
// HTTP link
const httpLink = createHttpLink({
uri: process.env.REACT_APP_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
});
// Auth link
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('authToken');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
'Apollo-Require-Preflight': 'true',
}
};
});
// Error link
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
);
// Handle specific error types
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('authToken');
window.location.href = '/login';
}
});
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
// Handle network errors
if (networkError.statusCode === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login';
}
}
});
// Retry link
const retryLink = new RetryLink({
delay: {
initial: 300,
max: Infinity,
jitter: true
},
attempts: {
max: 3,
retryIf: (error, _operation) => !!error
}
});
// Apollo Client instance
export const apolloClient = new ApolloClient({
link: from([errorLink, retryLink, authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
}
},
users: {
merge(existing = [], incoming) {
return incoming;
}
}
}
},
User: {
fields: {
posts: {
merge(existing = [], incoming) {
return incoming;
}
}
}
}
}
}),
defaultOptions: {
watchQuery: {
errorPolicy: 'all',
notifyOnNetworkStatusChange: true
},
query: {
errorPolicy: 'all'
}
}
});
GraphQL Queries và Mutations
TypeScript
// graphql/queries.ts
import { gql } from '@apollo/client';
// Fragments for reusability
export const USER_FRAGMENT = gql`
fragment UserInfo on User {
id
name
email
avatar
role
isActive
createdAt
profile {
bio
website
location
}
}
`;
export const POST_FRAGMENT = gql`
fragment PostInfo on Post {
id
title
content
excerpt
slug
publishedAt
updatedAt
readingTime
views
likes
featured
coverImage
author {
...UserInfo
}
category {
id
name
slug
color
}
tags
comments {
id
content
author {
...UserInfo
}
createdAt
}
}
${USER_FRAGMENT}
`;
// Queries
export const GET_USERS = gql`
query GetUsers($limit: Int, $offset: Int, $search: String, $role: UserRole) {
users(limit: $limit, offset: $offset, search: $search, role: $role) {
data {
...UserInfo
postsCount
lastActive
}
pagination {
total
page
limit
hasNextPage
hasPrevPage
}
}
}
${USER_FRAGMENT}
`;
export const GET_POSTS = gql`
query GetPosts(
$limit: Int
$offset: Int
$categoryId: ID
$featured: Boolean
$search: String
$sortBy: PostSortInput
) {
posts(
limit: $limit
offset: $offset
categoryId: $categoryId
featured: $featured
search: $search
sortBy: $sortBy
) {
data {
...PostInfo
}
pagination {
total
page
limit
hasNextPage
hasPrevPage
}
}
}
${POST_FRAGMENT}
`;
export const GET_POST_BY_SLUG = gql`
query GetPostBySlug($slug: String!) {
postBySlug(slug: $slug) {
...PostInfo
seo {
metaTitle
metaDescription
keywords
}
}
}
${POST_FRAGMENT}
`;
// Mutations
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
...PostInfo
}
}
${POST_FRAGMENT}
`;
export const UPDATE_POST = gql`
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
...PostInfo
}
}
${POST_FRAGMENT}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
success
message
}
}
`;
export const LIKE_POST = gql`
mutation LikePost($postId: ID!) {
likePost(postId: $postId) {
id
likes
isLiked
}
}
`;
// Subscriptions
export const POST_UPDATED = gql`
subscription PostUpdated($postId: ID!) {
postUpdated(postId: $postId) {
...PostInfo
}
}
${POST_FRAGMENT}
`;
export const NEW_COMMENT = gql`
subscription NewComment($postId: ID!) {
newComment(postId: $postId) {
id
content
author {
...UserInfo
}
createdAt
}
}
${USER_FRAGMENT}
`;
React Components với Apollo Hooks
TypeScript
// components/PostList.tsx
import React, { useState, useCallback } from 'react';
import {
useQuery,
useMutation,
useSubscription,
useLazyQuery
} from '@apollo/client';
import {
GET_POSTS,
DELETE_POST,
LIKE_POST,
POST_UPDATED
} from '../graphql/queries';
import { PostCard } from './PostCard';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
import { Pagination } from './Pagination';
interface PostListProps {
categoryId?: string;
featured?: boolean;
}
interface PostsData {
posts: {
data: Post[];
pagination: {
total: number;
page: number;
limit: number;
hasNextPage: boolean;
hasPrevPage: boolean;
};
};
}
interface Post {
id: string;
title: string;
content: string;
excerpt: string;
slug: string;
publishedAt: string;
author: {
id: string;
name: string;
avatar: string;
};
category: {
id: string;
name: string;
color: string;
};
likes: number;
views: number;
coverImage: string;
tags: string[];
}
export const PostList: React.FC<PostListProps> = ({
categoryId,
featured
}) => {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState({ field: 'publishedAt', order: 'DESC' });
const limit = 10;
const offset = (currentPage - 1) * limit;
// Main query
const {
data,
loading,
error,
refetch,
fetchMore,
networkStatus
} = useQuery<PostsData>(GET_POSTS, {
variables: {
limit,
offset,
categoryId,
featured,
search: searchTerm || undefined,
sortBy
},
notifyOnNetworkStatusChange: true,
errorPolicy: 'all',
fetchPolicy: 'cache-and-network'
});
// Lazy query for search
const [searchPosts, {
data: searchData,
loading: searchLoading
}] = useLazyQuery(GET_POSTS, {
errorPolicy: 'all'
});
// Mutations
const [deletePost] = useMutation(DELETE_POST, {
update(cache, { data: { deletePost } }) {
if (deletePost.success) {
// Remove from cache
cache.modify({
fields: {
posts(existingPosts = [], { readField }) {
return {
...existingPosts,
data: existingPosts.data.filter(
(post: any) => readField('id', post) !== deletePost.id
)
};
}
}
});
}
},
onCompleted: () => {
refetch();
}
});
const [likePost] = useMutation(LIKE_POST, {
optimisticResponse: (vars) => ({
likePost: {
__typename: 'Post',
id: vars.postId,
likes: 0, // Will be updated with real value
isLiked: true
}
}),
update(cache, { data: { likePost } }) {
cache.modify({
id: cache.identify({ __typename: 'Post', id: likePost.id }),
fields: {
likes: () => likePost.likes,
isLiked: () => likePost.isLiked
}
});
}
});
// Subscription for real-time updates
useSubscription(POST_UPDATED, {
variables: { postId: null }, // Listen to all posts
onSubscriptionData: ({ subscriptionData }) => {
if (subscriptionData.data) {
// Update cache with new data
refetch();
}
}
});
// Handlers
const handleSearch = useCallback((term: string) => {
setSearchTerm(term);
setCurrentPage(1);
if (term.trim()) {
searchPosts({
variables: {
limit,
offset: 0,
search: term,
categoryId,
featured,
sortBy
}
});
}
}, [searchPosts, categoryId, featured, sortBy, limit]);
const handleDeletePost = async (postId: string) => {
if (window.confirm('Are you sure you want to delete this post?')) {
try {
await deletePost({ variables: { id: postId } });
} catch (error) {
console.error('Error deleting post:', error);
}
}
};
const handleLikePost = async (postId: string) => {
try {
await likePost({ variables: { postId } });
} catch (error) {
console.error('Error liking post:', error);
}
};
const handleLoadMore = () => {
if (data?.posts.pagination.hasNextPage) {
fetchMore({
variables: {
offset: data.posts.data.length
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
posts: {
...fetchMoreResult.posts,
data: [
...prev.posts.data,
...fetchMoreResult.posts.data
]
}
};
}
});
}
};
if (loading && !data) return <LoadingSpinner />;
if (error && !data) return <ErrorMessage error={error} onRetry={() => refetch()} />;
const posts = searchData?.posts || data?.posts;
const isSearching = searchLoading || (searchTerm && !searchData);
return (
<div className="post-list">
{/* Search Bar */}
<div className="search-bar">
<input
type="text"
placeholder="Search posts..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="search-input"
/>
<select
value={`${sortBy.field}-${sortBy.order}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-');
setSortBy({ field, order });
}}
className="sort-select"
>
<option value="publishedAt-DESC">Newest First</option>
<option value="publishedAt-ASC">Oldest First</option>
<option value="likes-DESC">Most Liked</option>
<option value="views-DESC">Most Viewed</option>
<option value="title-ASC">Title A-Z</option>
</select>
</div>
{/* Loading indicator for refetch */}
{(loading || isSearching) && data && (
<div className="loading-overlay">Updating...</div>
)}
{/* Posts Grid */}
{posts?.data.length ? (
<div className="posts-grid">
{posts.data.map((post: Post) => (
<PostCard
key={post.id}
post={post}
onDelete={handleDeletePost}
onLike={handleLikePost}
/>
))}
</div>
) : (
<div className="empty-state">
<p>No posts found.</p>
</div>
)}
{/* Load More Button */}
{posts?.pagination.hasNextPage && (
<div className="load-more">
<button
onClick={handleLoadMore}
disabled={loading}
className="load-more-btn"
>
{loading ? 'Loading...' : 'Load More Posts'}
</button>
</div>
)}
{/* Pagination */}
{posts?.pagination && posts.pagination.total > limit && (
<Pagination
currentPage={currentPage}
totalPages={Math.ceil(posts.pagination.total / limit)}
onPageChange={setCurrentPage}
/>
)}
</div>
);
};
Custom Apollo Hooks
TypeScript
// hooks/usePostOperations.ts
import { useMutation, useApolloClient } from '@apollo/client';
import { CREATE_POST, UPDATE_POST, DELETE_POST, GET_POSTS } from '../graphql/queries';
export const usePostOperations = () => {
const client = useApolloClient();
const [createPostMutation, { loading: creating }] = useMutation(CREATE_POST, {
update(cache, { data: { createPost } }) {
// Add new post to the beginning of the list
cache.updateQuery({ query: GET_POSTS }, (data) => {
if (data?.posts) {
return {
posts: {
...data.posts,
data: [createPost, ...data.posts.data]
}
};
}
return data;
});
}
});
const [updatePostMutation, { loading: updating }] = useMutation(UPDATE_POST);
const [deletePostMutation, { loading: deleting }] = useMutation(DELETE_POST, {
update(cache, { data: { deletePost } }, { variables }) {
if (deletePost.success) {
cache.evict({
id: cache.identify({ __typename: 'Post', id: variables.id })
});
cache.gc();
}
}
});
const createPost = async (input: any) => {
try {
const result = await createPostMutation({ variables: { input } });
return result.data.createPost;
} catch (error) {
throw error;
}
};
const updatePost = async (id: string, input: any) => {
try {
const result = await updatePostMutation({
variables: { id, input },
optimisticResponse: {
updatePost: {
__typename: 'Post',
id,
...input
}
}
});
return result.data.updatePost;
} catch (error) {
throw error;
}
};
const deletePost = async (id: string) => {
try {
const result = await deletePostMutation({ variables: { id } });
return result.data.deletePost;
} catch (error) {
throw error;
}
};
const clearCache = () => {
client.resetStore();
};
return {
createPost,
updatePost,
deletePost,
clearCache,
loading: creating || updating || deleting
};
};
GraphQL tip: Sử dụng fragments để avoid duplication và optimistic updates cho better UX!
Kết luận
GraphQL với Apollo Client mang lại efficient data fetching và excellent developer experience.
Ngo Van I
Full Stack Developer chuyên GraphQL và modern web technologies.