LY Corporation Tech Blog

Quảng bá công nghệ và văn hoá phát triển hỗ trợ cho các dịch vụ cung cấp bởi LY Corporation và LY Corporation Group (LINE Plus, LINE Taiwan, LINE Vietnam).

Tối ưu hóa server state với Query

Vấn đề: Server state và client state

Khi làm việc với frontend, có thể bạn đã không còn xa lạ với khái niệm Quản lý state nữa. Nói 1 cách ngắn gọn, nó là 1 thư viện giúp bạn quản lý dữ liệu (state) trong app của mình 1 cách đồng bộ & dễ đoán nhờ khả năng:

  • Tập trung hóa dữ liệu: tất cả dữ liệu (global state) đều được lưu trữ ở 1 nơi trong thư viện gọi là store
  • Tập trung quyền lực: khi dữ liệu là chỉ-đọc và chỉ có thể thay đổi khi thông qua hàm của thư viện

Mạnh mẽ là vậy, tuy nhiên ở môi trường thực tế, khi ứng dụng của bạn phải lấy dữ liệu từ bên ngoài, 1 số vấn đề sẽ phát sinh. Để dễ hiểu, ta có thể chia dữ liệu toàn ứng dụng (global state) của mình thành 2 loại:

  • Client state: 
    • Hoàn toàn được định nghĩa & quản lý bởi ứng dụng (client) - là người dùng duy nhất ở 1 phiên làm việc: ví dụ như trạng thái ẩn/hiện của popup, nội dung nhập bởi người dùng…
    • Có tính đồng bộ (synchronous)
    • Trạng thái có thể thay đổi nhưng dễ đoán
    • Luôn ở trạng thái mới nhất
  • Server state: 
    • Phụ thuộc vào server bên ngoài, thông qua hình thức lấy dữ liệu (fetching) từ api. Có thể quản lý & thay đổi bởi nhiều người ở 1 phiên làm việc.
    • Bất đồng bộ (asynchronous)
    • Trạng thái khó đoán vì nằm ngoài kiểm soát
    • Nội dung có khả năng bị lỗi thời sau 1 thời gian (so với server)

Chính vì khác nhau như vậy, chỉ dùng 1 cách để quản lý 2 loại dữ liệu này dễ đem lại thiếu sót. Để đối phó với những thiếu sót này, bạn phải làm thủ công khá nhiều công việc để có thể đem lại trải nghiệm tốt cho người dùng. Sau đây là 1 ví dụ:

src/actions/todos.js

import axios from 'axios'
import {
 FETCH_TODOS_STARTED,
 FETCH_TODOS_SUCCEEDED,
 FETCH_TODOS_FAILED,
} from '../constants/todos'
 
export const fetchTodosStarted = () => ({
 type: FETCH_TODOS_STARTED,
})
 
export const fetchTodosSucceeded = (todos) => ({
 type: FETCH_TODOS_SUCCEEDED,
 todos,
})
 
export const fetchTodosFailed = (error) => ({
 type: FETCH_TODOS_FAILED,
 error,
})
 
export const fetchTodos = () => {
 return async (dispatch) => {
   dispatch(fetchTodosStarted())
 
   try {
     // Axios is common, but also `fetch`, or your own "API service" layer
     const res = await axios.get('/todos')
     dispatch(fetchTodosSucceeded(res.data))
   } catch (err) {
     dispatch(fetchTodosFailed(err))
   }
 }
}

src/reducers/todos.js

import {
  FETCH_TODOS_STARTED,
  FETCH_TODOS_SUCCEEDED,
  FETCH_TODOS_FAILED,
} from '../constants/todos'
 
const initialState = {
  status: 'uninitialized',
  todos: [],
  error: null,
}
 
export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case FETCH_TODOS_STARTED: {
      return {
        ...state,
        status: 'loading',
      }
    }
    case FETCH_TODOS_SUCCEEDED: {
      return {
        ...state,
        status: 'succeeded',
        todos: action.todos,
      }
    }
    case FETCH_TODOS_FAILED: {
      return {
        ...state,
        status: 'failed',
        todos: [],
        error: action.error,
      }
    }
    default:
      return state
  }
}

Đây là 1 ví dụ từ thư viện Redux. Bởi tính bất đồng bộ của server state, ta phải xử lý thủ công các trạng thái loading cũng như lỗi trước & sau khi gọi api bằng 3 hàm riêng biệt, đồng thời xử lý chúng trong reducer. Bạn có thể thấy, nếu ứng dụng cần gọi nhiều api thì những việc này phải lặp đi lặp lại rất nhiều lần. Ngoài ra, server state cũng cần xử lý những vấn đề sau:

  • Caching
  • Xử lý api gọi trùng nhau cùng lúc
  • Tự động cập nhật nội dung mới nhất, 1 cách nhanh và chạy ngầm để không làm ảnh hưởng tới trải nghiệm người dùng.
  • Tối ưu hóa bằng cách nhớ (memoiz), lazy load hay xử lý dữ liệu rác (garbage collector)

Tất nhiên, những tính năng trên không phải là bắt buộc mà nó là những điểm cộng mà bạn có thể áp dụng cho ứng dụng của mình. Hơn nữa, nếu việc áp dụng chúng rất đơn giản & đã được xử lý phần lớn bên trong bởi thư viện thì tội gì mà không thử phải không :). Đó là lý do tôi muốn giới thiệu tới khái niệm Query.

Query là gì ?

Query là đại diện cho 1 hàm bất đồng bộ (asynchronous) để lấy dữ liệu từ server, được phân biệt bởi 1 key không trùng lặp & duy nhất (unique key) tương ứng với hàm đó trong toàn ứng dụng. Query nhận hàm bất đồng bộ và sử dụng unique key này để xử lý tất cả các tính năng của nó, sau đó tự động trả về kết quả từ server cũng như trạng thái của hàm bất đồng bộ qua các giai đoạn. Bạn có thể thấy chúng trong ví dụ sau:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
 
export const api = createApi({
  baseQuery: fetchBaseQuery({
    // Fill in your own server starting URL here
    baseUrl: '/',
  }),
  endpoints: (build) => ({
    // A query endpoint with no arguments
    getTodos: build.query({
      query: () => '/todos',
    }),
    // A mutation endpoint
    updateTodo: build.mutation({
      query: (updatedTodo) => ({
        url: `/todos/${updatedTodo.id}`,
        method: 'POST',
        body: updatedTodo,
      }),
    }),
  }),
})
 
export const { useGetTodosQuery, useUpdateTodoMutation } = api
import { useGetTodosQuery } from '../api/apiSlice'
 
export function TodoList() {
const { data: todos, isFetching, isSuccess } = useGetTodosQuery()
 
// omit rendering logic here
}

Ưu điểm & 1 số tính năng của query

  1. Code gọn và dễ đọc, không cần tạo connectorreducer hay middleware. Giảm sự phức tạp khi xử lý hàm bất đồng bộ, dễ quản lý và sửa đổi. Như bạn có thể thấy, cùng 1 vấn đề là quản lý loading và dữ liệu như ví dụ đầu nhưng cách làm khi xài query gọn hơn rất nhiều. Tuy chỉ là 1 vấn đề nhỏ nhưng có thể thấy query đã xử lý rất tốt.
  2. Caching: giảm tải cho server, tăng tốc độ bằng bộ nhớ đệm, hỗ trợ tùy biến theo cài đặt của bạn

    export const api = createApi({
       baseQuery: fetchBaseQuery({ baseUrl: '/' }),
       // global configuration for the api
       keepUnusedDataFor: 30,
       refetchOnFocus: true,
       refetchOnReconnect: true,
       endpoints: (builder) => ({
           getPosts: builder.query<Post[], number>({
               query: () => `posts`
           }),
       }),
    })

    Đoạn code trên sẽ lưu trữ dữ liệu vào bộ nhớ đệm trong 30s sau khi không còn sử dụng và làm mới nó khi quay lại tab hoặc mạng kết nối trở lại.

  3. Lấy dữ liệu có điều kiện: đây là 1 tính năng mà tôi thấy rất hữu ích và thường gặp phải khi phát triển ứng dụng. Một số trường hợp như sau: 
    • Chỉ xử lý 1 api khi có nhiều lần gọi trùng nhau trong 1 thời điểm: 1 số component độc lập với nhau nhưng gọi chung 1 api và khi chúng xuất hiện trong cùng 1 trang việc gọi lên server sẽ bị trùng lặp. Query tự động phát hiện dựa vào unique key và chỉ xử lý 1 api.
    • Gọi api song song: khi 1 nhóm api luôn gọi dùng nhau, với query chỉ cần gọi 1 api là đủ
      function App({ users }) {
        const userQueries = useQueries({
          queries: users.map((user) => {
            return {
              queryKey: ['user', user.id],
              queryFn: () => fetchUserById(user.id),
            }
          }),
        })
      }
       
    • Tự động gọi api phụ thuộc sau khi gọi api chính: sau khi gọi các hàm CRUD, với danh sách hiển thị hay table, bạn cần sửa dữ liệu thủ công hoặc gọi lại api listing để cập nhật thay đổi này. Query có thể tự động hóa quá trình này. 
      // Get the user
      const { data: user } = useQuery({
        queryKey: ['user', email],
        queryFn: getUserByEmail,
      })
       
      const userId = user?.id
       
      // Then get the user's projects
      const {
        status,
        fetchStatus,
        data: projects,
      } = useQuery({
        queryKey: ['projects', userId],
        queryFn: getProjectsByUser,
        // The query will not execute until the userId exists
        enabled: !!userId,
      })
  4. Tự động hóa phân trang(pagination) và infinite scroll
    • Khi thay đổi số trang, query sẽ tự động gọi api để cập nhật dữ liệu.
    • Khi cần gọi lại query trong infinite scroll, hàm bất đồng bộ sẽ được gọi theo thứ tự từ đầu, đảm bảo dữ liệu không bị thiếu/trùng lặp, và đặc biệt là giữ được vị trí của scroll khi thoát/quay trở lại trang.
  5. Hỗ trợ tự động hủy query khi hủy hàm bất đồng bộ hoặc hủy query thủ công sử dụng AbortController.
    const query = useQuery({
      queryKey: ['todos'],
      queryFn: async ({ signal }) => {
        const resp = await fetch('/todos', { signal })
        return resp.json()
      },
    })
     
    const queryClient = useQueryClient()
     
    return (
      <button
        onClick={(e) => {
          e.preventDefault()
          queryClient.cancelQueries({ queryKey: ['todos'] })
        }}
      >
        Cancel
      </button>
    )
  6. Type safety: các thư viện phổ biến như react-queryRTK-Query đều hỗ trợ Typescripts, giúp ích hơn cho việc quản lý code

    Và nhiều hơn nữa~

Nhược điểm

  1. Sử dụng query đồng nghĩa với việc thêm một lớp logic cho ứng dụng, khó áp dụng khi chuyển đổi từ ứng dụng lớn
    Tùy vào lợi ích mà bạn thấy query có thể mang lại cho ứng dụng của mình với thời gian và công sức bỏ ra, nhiều khi chỉ sử dụng thư viện quản lý bình thường là đã đủ.
  2. Chỉ tập trung quanh xử lý hàm bất đồng bộ và server state, không phù hợp để quản lý client state phức tạp

Các thư viện phổ biến hỗ trợ sử dụng query 

Lời kết

Query là 1 giải pháp hữu ích để đối phó với server state, tuy nhiên nó không phải là thay thế cho việc quản lý dữ liệu của ứng dụng. Mặc dù vậy, khi sử dụng query, client state còn lại của bạn sẽ nhỏ hơn rất nhiều. Vì thế, để sử dụng nó 1 cách tốt nhất, query cần được dùng chung với 1 trình quản lý client state khác để xử lý các dữ liệu phức tạp trong ứng dụng, như rtk-query + redux hay react-query + thư viện quản lý nhẹ cân như zustand. Tôi hy vọng bài viết này hữu ích trong việc giúp bạn cân nhắc cách tiếp cận việc quản lý dữ liệu bên trong ứng dụng của mình một cách tối ưu hơn.