ReactJS: Quản lý server state với react-query

Quản lý state trong React app là một bài toán phức tạp và vô cùng khó khăn. Bởi vì ngoài kia có rất rất nhiều thư viện và cách giải quyết khác nhau. Bạn có thể nghe đến một số em hot như Redux, React Context, Mobx, … đến các khái niệm như lifting state, collocation state, local state, global state.

Hậu quả nhiều FE framework đã chết vì đã không giải quyết tốt bài toán này như: Angular 1.x, BackboneJS, KnockoutJS, EmberJS, …

State là gì?

State là tất cả những dữ liệu bạn lưu trong ứng dụng, bao gồm các kiểu dữ liệu như string, number, boolean, array, function, object,… Và một đặc điểm của state là nó có thể thay đổi theo thời gian.

Nếu bạn dùng React và Redux thì bạn có thể biết đến local state (những state chỉ dùng trong một component như các biến isOpenModal, status, error, isLoading, …), global state (những state được chia sẻ cho nhiều component như userInfo, language, token, user settings, translation, …).

Thông thường chúng ta sẽ sử dụng React.useState để quản lí local state. Và sử dụng Redux để lưu những state được chia sẻ cho nhiều component. Hoặc nếu bạn thấy một số local state bị thay đổi nhiều và khó quản lí thì bạn cũng có thể cân nhắc sử dụng Redux cho một vài local state đăc biệt.

Quản lí state như thế nào là hợp lí?

Bạn có thể tham khảo chi tiết khi nào cần dùng local state và global state tại đây. Tóm tắt lại chúng ta có sơ đồ như sau:

where to put react state

Trường hợp 1: Bắt đầu bạn hãy nghĩ state đó chỉ được dùng trong một component và nếu không có vấn đề khi truyền props xuống cho component con với 2-3 cấp. Bạn cứ sử dụng local state với React.useState.

Trường hợp 2: Nếu local state của bạn bắt đầu truyền cho quá nhiều con (4-5 cấp gì đó). Và các con này có thể nằm bên ngoài hay bên trong cha của nó thì bạn có thể sử dụng Redux hay React.createContext để tạo global state, chia sẻ state sẽ dễ hơn.

Trường hợp 3: Trường hợp này có thể khó cài đặt hơn. Nếu bạn truyền state xuống cho nhiều con mà những con này nằm bên trong cha của nó (con này ko giống TH2) thì bạn có thể dùng cách component composition. Đẩy state lên component cha và truyền xuống con sử dụng {props.children}. Bạn có thể đọc bài viết này của Kent C.Dodds để hiểu cách sử dụng component composition để tránh được prop drilling (truyền props xuống cho quá nhiều component con). Trước khi bạn dùng useContext hãy tham khảo bài viết này.

Composition component là gì?

Nguyên tắc của composition component là một component lớn có thể được tạo thành từ nhiều component nhỏ. Và chúng ta có thể truyền những component con này như những props của component lớn. Ví dụ như sau:

// Trước khi sử dụng composition
<Page user={user} avatarSize={avatarSize} />
// ... which renders ...
<PageLayout user={user} avatarSize={avatarSize} />
// ... which renders ...
<NavigationBar user={user} avatarSize={avatarSize} />
// ... which renders ...
<Link href={user.permalink}>
  <Avatar user={user} size={avatarSize} />
</Link>

// Sau khi sử dụng composition
function Page(props) {
  const user = props.user;
  const content = <Feed user={user} />;
  const topBar = (
    <NavigationBar>
      <Link href={user.permalink}>
        <Avatar user={user} size={props.avatarSize} />
      </Link>
    </NavigationBar>
  );
  return (
    <PageLayout
      topBar={topBar}
      content={content}
    />
  );
}

Với composition bạn tránh được props drilling và cũng ko cần xài useContext. Theo nguyên tắc component <PageLayout /> có các props là topBar content là những component con được truyền vào.

Bạn có thể tham khảo thêm một lỗi phổ biến về cách tổ chức component trong React app. Bài viết này rất hay và tôi thấy ai cũng bị lỗi này, kể cả tôi 😀

Tại sao phải dùng react-query?

Các cách quản lí và phân chia local state và global state trên đây vẫn đang hoạt động tốt và tương đối phổ biến cho các dự án React hiện tại.

Nhưng có một cách phân chia tốt hơn là client stateserver state. Đây là ý tưởng từ react-query, một thư viện khá hot gần đây mà ai cũng nói đến.

Trước đây khi chúng ta muốn fetch data từ server, chúng ta có thể sử dụng window.fetch hay axios, nhưng cả hai cách này đều không xử lí caching data. Bởi vì thường có một nhu cầu là chúng ta muốn cache data của server trên client (để lấy nhanh hơn khi cần, và không còn gọi API lại). Và chúng ta thường tự xử lí (phần nào khá khoai và phát sinh nhiều vấn đề) hay dùng những thư viện khác. Với react-query, nó không những fetch data và còn xử lí phần caching, đồng bộ và cập nhật data giữa client và server. Và còn nhiều nhiều lợi ích khác ở đây.

Nhờ có react query mà chúng ta mới nhìn nhận lại rằng đáng lẽ ra có sự khác nhau giữ server state và client state.

  • Server state là những dữ liệu lưu ở phía server và chúng ta dùng API để lấy về trên client (React web). Ví dụ userInfo, listItems, propertyDetail, ….
  • Client state (UI state) là những dữ liệu chỉ tồn tại ở React và không được lưu xuống server như các biến isModalOpen, isLoading, status, … Những dữ liệu này sẽ mất đi khi chúng ta reload page.

Chúng ta có thể đã sai khi kết hợp hai state này chung với nhau trên Redux hay Context. Việc quản lí, cập nhật và đồng bộ hay cache những dữ liệu từ server nên phải được tách riêng ra và xử lí tốt hơn.

Một số đặc điểm nổi bật của server state là:

  • Nó nằm ở server và bạn ko thể điều khiển trực tiếp được nó.
  • Cần một async request để lấy và cập nhật
  • Nó có thể bị thay đổi bởi một ai đó mà bạn không biết
  • Nó bị cũ (stale) hay lỗi thời (outdated) trong quá trình sử dụng app

Với react-query chúng ta sẽ quản lí server state riêng biệt và hiệu quả hơn. Caching giúp cho app chạy nhanh và mượt mà hơn. Ví dụ bạn gọi API /posts để lấy tất cả bài posts khi bắt đầu load trang lên, sau đó click vào để xem chi tiết từng bài post (detail page). Khi bạn nhấn nút Back để trở lại trang posts thì mặc định bạn sẽ thấy data đã có sẵn từ cache và người dùng sẽ thấy data ngay lập tức (không cần chờ gọi API như sử dụng axios hay window.fetch). Điều đặc biệt là ngay lúc này react-query cũng gọi API ngầm bên dưới (background refetch) để update lại các bài posts tự động cho bạn. Cool 🙂

Xem ví dụ chi tiết ở đây. Và còn rất nhiều câu chuyện xoay quanh cách quản lí cache và fetch data để làm app chạy mượt mà hơn của react-query sẽ giúp bạn.

Khi nào data được refetch bạn có thể đọc chi tiết một ví dụ tại đây. Mặc định data sẽ được refetch khi:

  • Một useQuery('posts') hook được gọi – một instance mới của query đó
  • Browser tab được chọn: chuyển qua chuyển lại giữa các tab
  • Network được kết nối lại
  • useQuery('posts') được cấu hình refetchInterval

Tóm lại một số lợi ích của react-query

  • Caching data cho API
  • Hạn chế gọi nhiều request trùng nhau
  • Tự động cập nhật data của API bên dưới, giúp data luôn mới và đồng bộ với server
  • Phân trang và lazy loading
  • Điều khiển được data khi nó bị cũ, có thể gọi lại dễ dàng
  • Giúp tăng trải nghiệm UX cho web app với “instant” data

Sử dụng react-query

Giả sử chúng ta muốn cài đặt một trang như sau:

Yêu cầu là khi bấm vào nút “+” ở trên chúng ta sẽ gọi một POST request để tạo mới một list-items (Một item là một quyển sách) Chúng ta sẽ sử dụng các endpoint sau:

  • GET: list-items – lấy về tất cả các quyển sách trong list-items
  • POST: list-items – thêm một quyển sách vào list-items
  • PUT: list-items/${listItemId} – cập nhật một quyển sách
  • DELETE: list-items/${listItemId} -xoá một quyển sách ra khỏi list-items

Đầu tiên chúng ta gọi POST để thêm một quyển sách vào list-items (giống như một danh sách các quyển sách mà bạn yêu thích)

const [create] = useMutation(
    ({bookId}) => client(`list-items`, {data: {bookId}, token: user.token}),
    {onSettled: () => queryCache.invalidateQueries('list-items')},
)

Chúng ta sử dụng useMutation (được sử dụng cho những request làm thay đổi data như POST, PUT, DELETE) do đây là hành động thêm mới một item vào list. Sau khi thêm thành công một quyển sách chúng ta cần gọi queryCache.invalidateQueries('list-items') để lấy lại list-items mới nhất từ API. Khi chúng ta gọi invalidateQueries thì API sau được gọi lại:

const {data: listItems} = useQuery({
    queryKey: 'list-items',
    queryFn: () =>
      client(`list-items`, {token: user.token}).then(data => data.listItems),
})

Đây là query để get tất cả quyển sách bằng endpoint GET ở trên. Sau khi chúng ta thêm thành công thì có hai nút mới xuất hiện như sau:

Nút ở trên là “Đánh dấu đã đọc”, nút còn lại là “Xoá khỏi danh sách”
// đây là nút "Đánh dấu đã đọc" gọi một PUT
<TooltipButton
   label="Mark as read"
   highlight={colors.green}
   onClick={() => update({id: listItem.id, finishDate: Date.now()})}
   icon={<FaCheckCircle />}
 />

const [update] = useMutation(
    updates =>
      client(`list-items/${updates.id}`, {
        method: 'PUT',
        data: updates,
        token: user.token,
      }),
    {onSettled: () => queryCache.invalidateQueries('list-items')},
)

Tương tự nút “Xoá khỏi danh sách”

// đây là nút "Xoá khỏi danh sách" gọi một DELETE
<TooltipButton
  label="Remove from list"
  highlight={colors.danger}
  onClick={() => remove({id: listItem.id})}
  icon={<FaMinusCircle />}
/>

const [remove] = useMutation(
    ({id}) => client(`list-items/${id}`, {method: 'DELETE', token: user.token}),
    {onSettled: () => queryCache.invalidateQueries('list-items')},
)

Toàn bộ code như sau:

import * as React from 'react'
import {
  FaCheckCircle,
  FaPlusCircle,
  FaMinusCircle,
  FaBook,
  FaTimesCircle,
} from 'react-icons/fa'
import Tooltip from '@reach/tooltip'
import {useMutation, queryCache, useQuery} from 'react-query'
import {client} from 'utils/api-client'
import * as colors from 'styles/colors'
import {CircleButton, Spinner} from './lib'

function StatusButtons({user, book}) {
  const {data: listItems} = useQuery({
    queryKey: 'list-items',
    queryFn: () =>
      client(`list-items`, {token: user.token}).then(data => data.listItems),
  })
  const listItem = listItems?.find(li => li.bookId === book.id) ?? null

  const [remove] = useMutation(
    ({id}) => client(`list-items/${id}`, {method: 'DELETE', token: user.token}),
    {onSettled: () => queryCache.invalidateQueries('list-items')},
  )

  const [create] = useMutation(
    ({bookId}) => client(`list-items`, {data: {bookId}, token: user.token}),
    {onSettled: () => queryCache.invalidateQueries('list-items')},
  )

  const [update] = useMutation(
    updates =>
      client(`list-items/${updates.id}`, {
        method: 'PUT',
        data: updates,
        token: user.token,
      }),
    {onSettled: () => queryCache.invalidateQueries('list-items')},
  )

  return (
    <React.Fragment>
      {listItem ? (
        Boolean(listItem.finishDate) ? (
          <TooltipButton
            label="Unmark as read"
            highlight={colors.yellow}
            onClick={() => update({id: listItem.id, finishDate: null})}
            icon={<FaBook />}
          />
        ) : (
          <TooltipButton
            label="Mark as read"
            highlight={colors.green}
            onClick={() => update({id: listItem.id, finishDate: Date.now()})}
            icon={<FaCheckCircle />}
          />
        )
      ) : null}
      {listItem ? (
        <TooltipButton
          label="Remove from list"
          highlight={colors.danger}
          onClick={() => remove({id: listItem.id})}
          icon={<FaMinusCircle />}
        />
      ) : (
        <TooltipButton
          label="Add to list"
          highlight={colors.indigo}
          onClick={() => create({bookId: book.id})}
          icon={<FaPlusCircle />}
        />
      )}
    </React.Fragment>
  )
}

export {StatusButtons}

Cài đặt và sử dụng react-query không khó, quan trọng là chúng ta hiểu tại sao phải dùng react-query. Bởi vì khi thêm một thư viên nào vào dự án bạn cũng phải nên cân nhắc thật thận trọng. Vấn đề có khó và có đáng để sử dụng thư viện hay không. Bạn có thể tham khảo rất nhiều ví dụ trên trang react-query.

Xem thêm những thủ thuật xịn cho Javascript tại Youtube của tôi.

Tham khảo

Bookshelf của Kent C.Dodds

State Management của Kent C.Dodds

Trước khi sử dụng useContext

Learn react-query

6 Comments

  1. Anonymous

    “Với react-query chúng ta sẽ quản lí server state riêng biệt và hiệu quả hơn. Caching giúp cho app chạy nhanh và mượt mà hơn. Ví dụ bạn gọi API /posts để lấy tất cả bài posts khi bắt đầu load trang lên, sau đó click vào để xem chi tiết từng bài post (detail page). Khi bạn nhấn nút Back để trở lại trang posts thì mặc định bạn sẽ thấy data đã có sẵn từ cache và người dùng sẽ thấy data ngay lập tức (không cần chờ gọi API như sử dụng axios hay window.fetch). Điều đặc biệt là ngay lúc này react-query cũng gọi API ngầm bên dưới (background refetch) để update lại các bài posts tự động cho bạn”
    Em thấy nó vẫn call api mỗi lần back lại back ạ chẳng qua là nếu kết quả sau khi call api có thay đổi thì nó sẽ cập nhật lại đúng k ạ? a có thể giải thích kĩ hơn về quá trình cache k ạ

    • admin

      Đúng rùi em, API vẫn gọi lại nhưng đầu tiên nó sẽ lấy dữ liệu từ cache trước và sau đó nó mới gọi lại API. Điều này giúp người dùng tăng trải nghiệm hơn, vì lấy data từ cache sẽ nhanh hơn.

Leave a Reply

Your email address will not be published. Required fields are marked *