Web DevelopmentNổi bật

GraphQL with Apollo Client

Ngo Van I

Ngo Van I

Full Stack Developer

04-01
16p
1k
#GraphQL#Apollo Client#React#TypeScript
GraphQL with Apollo Client

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 Apollo

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

Ngo Van I

Full Stack Developer chuyên GraphQL và modern web technologies.