Bài 25.1: Giới thiệu GraphQL và Apollo Client

Trong hành trình xây dựng các ứng dụng web Front-end phức tạp với React và Next.js, việc quản lý dữ liệu là một trong những thách thức lớn nhất. Chúng ta cần tìm cách hiệu quả để giao tiếp với API backend, lấy dữ liệu cần thiết một cách linh hoạt, quản lý trạng thái tải, lỗi, và đặc biệt là bộ nhớ đệm (cache) để tối ưu hiệu suất. Đây chính là lúc mà bộ đôi GraphQLApollo Client tỏa sáng, mang đến một luồng công việc hiện đại và mạnh mẽ cho việc quản lý dữ liệu.

Tại sao lại là GraphQL? Vấn đề với REST truyền thống

Trước khi đi sâu vào GraphQL, hãy cùng nhìn lại mô hình API REST phổ biến mà có lẽ bạn đã quen thuộc. REST hoạt động dựa trên các tài nguyên (resources) và các điểm cuối (endpoints) cố định. Ví dụ, bạn có thể có các endpoint như /users, /products/{id}, /orders.

Mặc dù REST đã chứng minh hiệu quả trong nhiều năm, nó vẫn tồn tại một số hạn chế khi nhu cầu của Front-end trở nên phức tạp hơn:

  1. Over-fetching (Lấy dư thừa dữ liệu): Bạn yêu cầu dữ liệu từ một endpoint (ví dụ /users), và API trả về tất cả các trường dữ liệu của người dùng đó (ID, tên, email, địa chỉ, ngày tạo, ngày cập nhật, v.v.), ngay cả khi bạn chỉ cần tên và email. Việc này làm tốn băng thông và tài nguyên xử lý ở cả client và server.
  2. Under-fetching (Lấy thiếu dữ liệu): Để hiển thị thông tin của một người dùng cùng với 5 bài viết gần đây nhất của họ, bạn có thể cần thực hiện nhiều yêu cầu HTTP riêng biệt: một cho thông tin người dùng (/users/{id}) và một cho danh sách bài viết của người dùng đó (/users/{id}/posts). Việc này dẫn đến vấn đề "N+1 requests" (N+1 yêu cầu), làm chậm ứng dụng.
  3. Ít linh hoạt cho Front-end: Khi nhu cầu dữ liệu của Front-end thay đổi (ví dụ: cần thêm một trường dữ liệu mới), bạn thường phải đợi backend tạo hoặc chỉnh sửa một endpoint REST mới, làm chậm quá trình phát triển.

GraphQL ra đời như một giải pháp cho những vấn đề này.

GraphQL: Ngôn ngữ truy vấn cho API của bạn

GraphQL không phải là một cơ sở dữ liệu, nó là một ngôn ngữ truy vấn (query language) cho API của bạn và một runtime để thực thi các truy vấn đó dựa trên một schema được định nghĩa sẵn. Ý tưởng cốt lõi của GraphQL là: client (Front-end) sẽ chỉ định chính xác cấu trúc dữ liệu mà nó cần, và server sẽ trả về đúng cấu trúc đó.

Điều này được thực hiện thông qua một schema mạnh mẽ, định nghĩa tất cả các loại dữ liệu (Types) và các thao tác có thể thực hiện (Queries, Mutations, Subscriptions).

Các khái niệm chính trong GraphQL:

  • Schema: Là trái tim của một API GraphQL. Nó định nghĩa các kiểu dữ liệu (Types), các trường (Fields) của mỗi kiểu, và các thao tác đọc (Queries), ghi/sửa (Mutations), và nhận cập nhật theo thời gian thực (Subscriptions) mà client có thể thực hiện. Schema đảm bảo rằng client chỉ có thể yêu cầu những gì đã được định nghĩa.
  • Types: Tương tự như các lớp (classes) hoặc đối tượng (objects) trong lập trình. Ví dụ: User Type có các trường id, name, email.
  • Fields: Là các thuộc tính của một Type.
  • Queries: Là các yêu cầu để đọc dữ liệu. Khi thực hiện một query, client chỉ định rõ ràng các trường dữ liệu mà nó muốn nhận.
  • Mutations: Là các yêu cầu để thay đổi dữ liệu (tạo, cập nhật, xóa).
  • Subscriptions: Cho phép client nhận cập nhật dữ liệu theo thời gian thực khi có sự thay đổi trên server.

Ví dụ về một GraphQL Query đơn giản:

Giả sử chúng ta cần lấy thông tin của một người dùng cụ thể, chỉ cần ID, tên và email. Thay vì gọi /users/{id} và nhận về tất cả, với GraphQL, chúng ta viết query như sau:

query GetUserNameAndEmail {
  user(id: "123") {
    id
    name
    email
  }
}

Và server chỉ trả về đúng những gì được yêu cầu:

{
  "data": {
    "user": {
      "id": "123",
      "name": "Nguyen Van A",
      "email": "a.nguyen@example.com"
    }
  }
}

Nếu sau đó bạn cần thêm trường ngày tạo tài khoản (createdAt), bạn chỉ cần sửa query mà không cần thay đổi endpoint hay logic phía server (miễn là trường đó đã được định nghĩa trong schema):

query GetUserFullInfo {
  user(id: "123") {
    id
    name
    email
    createdAt # Thêm trường mới
  }
}

Đây chính là sức mạnh của GraphQL: linh hoạt và hiệu quả.

Apollo Client: Cầu nối mạnh mẽ từ Front-end đến GraphQL API

GraphQL cung cấp ngôn ngữ và runtime phía server, nhưng ở phía Front-end, chúng ta cần một thư viện để giúp việc gửi các query/mutation, nhận kết quả, quản lý trạng thái, và đặc biệt là xử lý bộ nhớ đệm trở nên dễ dàng. Có nhiều thư viện client cho GraphQL, và Apollo Client là một trong những lựa chọn phổ biến và mạnh mẽ nhất, đặc biệt được ưa chuộng trong hệ sinh thái React/Next.js.

Apollo Client là một thư viện toàn diện giúp bạn tương tác với bất kỳ API GraphQL nào từ ứng dụng Front-end của mình. Nó cung cấp nhiều tính năng hữu ích:

  • Fetching data: Dễ dàng thực hiện các Query và Mutation thông qua các hook React (như useQuery, useMutation).
  • Caching: Apollo Client có một bộ nhớ đệm chuẩn hóa trong bộ nhớ (InMemoryCache) giúp lưu trữ dữ liệu đã fetch. Khi bạn yêu cầu dữ liệu mà đã có trong cache, Apollo sẽ trả về ngay lập tức, giúp ứng dụng phản hồi nhanh hơn và giảm số lượng request đến server. Cache này cũng tự động cập nhật khi bạn thực hiện các Mutation.
  • State Management: Có khả năng quản lý trạng thái cục bộ của ứng dụng, tích hợp cả dữ liệu từ GraphQL API và dữ liệu chỉ tồn tại ở client.
  • Error Handling & Loading States: Cung cấp các trạng thái loadingerror một cách tự nhiên khi thực hiện các thao tác GraphQL, giúp bạn dễ dàng hiển thị giao diện tương ứng cho người dùng.
  • Integration with Frameworks: Tích hợp mượt mà với các framework Front-end phổ biến như React, Vue, Angular.
Bắt đầu với Apollo Client trong React/Next.js

Hãy xem cách chúng ta tích hợp Apollo Client vào một ứng dụng React/Next.js cơ bản.

Bước 1: Cài đặt các gói cần thiết

npm install @apollo/client graphql
# hoặc yarn add @apollo/client graphql

@apollo/client là thư viện chính của Apollo Client. graphql là gói cần thiết để phân tích cú pháp các câu lệnh GraphQL (query, mutation).

Bước 2: Cấu hình Apollo Client

Bạn cần tạo một instance của ApolloClient và cấu hình URI của API GraphQL backend, cùng với bộ nhớ đệm. Sau đó, bọc ứng dụng của bạn trong ApolloProvider.

// pages/_app.js hoặc index.js (trong React)

import { ApolloClient, InMemoryCache, ApolloProvider, gql } from '@apollo/client';
import React from 'react';
import '../styles/globals.css'; // CSS global của bạn

// Khởi tạo một instance của Apollo Client
const client = new ApolloClient({
  uri: 'YOUR_GRAPHQL_ENDPOINT', // <<< THAY THẾ BẰNG ĐIỂM CUỐI GRAPHQL CỦA BẠN
  cache: new InMemoryCache(), // Sử dụng bộ nhớ đệm trong bộ nhớ
});

function MyApp({ Component, pageProps }) {
  return (
    // Bọc ứng dụng của bạn trong ApolloProvider
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

Trong đoạn code trên:

  • Chúng ta import các class cần thiết từ @apollo/client.
  • Tạo một instance client mới, chỉ định uri là địa chỉ của API GraphQL backend của bạn.
  • Sử dụng InMemoryCache làm bộ nhớ đệm mặc định.
  • Bọc component gốc của ứng dụng (MyApp hoặc App) trong ApolloProvider và truyền instance client vào prop client. Điều này giúp tất cả các component con trong cây ứng dụng có thể truy cập vào client Apollo.

Bước 3: Fetch dữ liệu với useQuery

Để lấy dữ liệu, chúng ta sử dụng hook useQuery. Hook này sẽ tự động thực hiện query, quản lý trạng thái loading, error, và trả về dữ liệu khi có kết quả.

Trước hết, định nghĩa query bằng cách sử dụng tag gql:

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

// Định nghĩa GraphQL Query
const GET_USERS = gql`
  query GetAllUsers {
    users {
      id
      name
      email
    }
  }
`;

Sau đó, sử dụng useQuery trong component React của bạn:

import { useQuery, gql } from '@apollo/client';
// Import định nghĩa query GET_USERS từ file riêng nếu cần
// import { GET_USERS } from './graphql/queries';

const GET_USERS = gql`
  query GetAllUsers {
    users {
      id
      name
      email
    }
  }
`;

function UsersList() {
  // Sử dụng hook useQuery
  const { loading, error, data } = useQuery(GET_USERS);

  if (loading) return <p>Đang tải người dùng...</p>;
  if (error) return <p>Lỗi khi tải người dùng: {error.message}</p>;

  // Dữ liệu trả về nằm trong data.users (tên query là "users" trong ví dụ này)
  return (
    <div>
      <h2>Danh sách Người dùng</h2>
      <ul>
        {data.users.map(user => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
}

Trong ví dụ này:

  • useQuery(GET_USERS) thực thi query GET_USERS.
  • Hook trả về một object chứa ba thuộc tính chính: loading (boolean, true khi đang fetch), error (object, chứa thông tin lỗi nếu có), và data (object, chứa dữ liệu kết quả nếu thành công).
  • Chúng ta sử dụng các trạng thái loadingerror để hiển thị giao diện phản hồi cho người dùng.
  • Khi dữ liệu thành công, chúng ta truy cập dữ liệu từ data.users (tên của trường gốc trong query).

Sử dụng biến (Variables) trong Queries:

Thông thường, bạn sẽ cần truyền các tham số động vào query (ví dụ: ID của người dùng cần lấy). Bạn sử dụng cú pháp biến trong GraphQL và truyền biến vào hook useQuery.

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

const GET_USER_BY_ID = gql`
  query GetUser($userId: ID!) { # Định nghĩa biến $userId kiểu ID, yêu cầu không null (!)
    user(id: $userId) {         # Sử dụng biến trong query
      id
      name
      email
      posts {
        title
        publishedAt
      }
    }
  }
`;
import { useQuery, gql } from '@apollo/client';
// import { GET_USER_BY_ID } from './graphql/queries';

const GET_USER_BY_ID = gql`
  query GetUser($userId: ID!) {
    user(id: $userId) {
      id
      name
      email
      posts {
        title
        publishedAt
      }
    }
  }
`;

function UserProfile({ userId }) {
  // Truyền biến vào options object của useQuery
  const { loading, error, data } = useQuery(GET_USER_BY_ID, {
    variables: { userId: userId }, // key trong variables object phải khớp với tên biến trong query ($userId)
  });

  if (loading) return <p>Đang tải thông tin người dùng...</p>;
  if (error) return <p>Lỗi: {error.message}</p>;

  const user = data.user;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <h3>Bài viết:</h3>
      <ul>
        {/* Kiểm tra nếu có bài viết trước khi map */}
        {user.posts && user.posts.map(post => (
          <li key={post.title}>{post.title} ({post.publishedAt})</li>
        ))}
      </ul>
    </div>
  );
}

Bước 4: Thay đổi dữ liệu với useMutation

Khi bạn cần tạo, cập nhật, hoặc xóa dữ liệu, bạn sử dụng Mutation. useMutation hook cung cấp một hàm để kích hoạt mutation.

Định nghĩa Mutation:

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

const CREATE_POST_MUTATION = gql`
  mutation CreatePost($title: String!, $content: String!, $authorId: ID!) {
    createPost(input: { title: $title, content: $content, authorId: $authorId }) {
      id
      title
      # Có thể yêu cầu trả về các trường khác của bài viết mới tạo nếu cần
      author {
          name
      }
    }
  }
`;

Sử dụng useMutation trong component:

import { useMutation, gql } from '@apollo/client';
// import { CREATE_POST_MUTATION } from './graphql/mutations';

const CREATE_POST_MUTATION = gql`
  mutation CreatePost($title: String!, $content: String!, $authorId: ID!) {
    createPost(input: { title: $title, content: $content, authorId: $authorId }) {
      id
      title
      author {
          name
      }
    }
  }
`;

function CreatePostForm({ authorId }) {
  // useMutation trả về một tuple: [hàm mutate, kết quả/trạng thái]
  const [createPost, { data, loading, error }] = useMutation(CREATE_POST_MUTATION);

  const handleSubmit = async (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    const title = formData.get('title');
    const content = formData.get('content');

    try {
      // Gọi hàm mutate với các biến
      const result = await createPost({
        variables: {
          title,
          content,
          authorId // Sử dụng authorId được truyền vào component
        },
      });
      console.log('Bài viết mới được tạo:', result.data.createPost);
      // TODO: Cập nhật bộ nhớ đệm (cache) nếu cần thiết để danh sách bài viết mới hiển thị
      // ví dụ: client.writeQuery hoặc refetchQueries
    } catch (err) {
      console.error('Lỗi khi tạo bài viết:', err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="title">Tiêu đề:</label>
        <input id="title" name="title" type="text" required disabled={loading} />
      </div>
      <div>
        <label htmlFor="content">Nội dung:</label>
        <textarea id="content" name="content" required disabled={loading} />
      </div>
      <button type="submit" disabled={loading}>
        {loading ? 'Đang tạo...' : 'Tạo Bài Viết'}
      </button>
      {error && <p style={{ color: 'red' }}>Lỗi: {error.message}</p>}
      {data && <p style={{ color: 'green' }}>Tạo bài viết thành công: "{data.createPost.title}"</p>}
    </form>
  );
}

Trong ví dụ này:

  • useMutation(CREATE_POST_MUTATION) trả về một tuple: hàm createPost (để gọi mutation) và một object trạng thái (data, loading, error).
  • Hàm createPost được gọi bên trong handleSubmit, truyền các biến cần thiết thông qua object variables.
  • Chúng ta xử lý kết quả trả về hoặc bắt lỗi từ lời gọi mutation.
  • Lưu ý quan trọng: Sau khi thực hiện một mutation thành công, bạn thường cần cập nhật bộ nhớ đệm của Apollo Client hoặc refetch các query liên quan để giao diện người dùng phản ánh dữ liệu mới nhất. Đây là một khía cạnh nâng cao hơn của Apollo Client.
Lợi ích khi sử dụng GraphQL và Apollo Client

Kết hợp GraphQL và Apollo Client mang lại nhiều lợi ích đáng kể cho quá trình phát triển Front-end:

  • Tăng hiệu suất: Giảm lượng dữ liệu fetch không cần thiết và giảm số lượng request HTTP.
  • Phát triển nhanh hơn: Front-end có thể yêu cầu chính xác dữ liệu cần mà không cần chờ đợi backend sửa đổi endpoint REST. Code Front-end trở nên dễ đọc và bảo trì hơn với các định nghĩa query rõ ràng.
  • Trải nghiệm nhà phát triển tốt hơn: Schema mạnh mẽ giúp việc xác định kiểu dữ liệu và các thao tác trở nên tường minh. Các công cụ như Apollo DevTools giúp debug dễ dàng.
  • Quản lý trạng thái tập trung: Apollo Cache hoạt động như một nguồn dữ liệu đáng tin cậy, giúp đơn giản hóa việc quản lý trạng thái dữ liệu từ server.

Với sự kết hợp của GraphQL làm ngôn ngữ giao tiếp API linh hoạt và Apollo Client làm thư viện quản lý dữ liệu Front-end mạnh mẽ, bạn có trong tay bộ công cụ hiện đại để xây dựng các ứng dụng web phức tạp, hiệu quả và dễ bảo trì hơn bao giờ hết.

Comments

There are no comments at the moment.