≪ Today I learned.
RSS購読
    公開日
    タグ
    JavaScript, React
    著者
    ダーシノ

    TanStack Queryの利用頻度が高いAPIを学ぶ

    Reactを使ったWebアプリケーションの開発では、React Hooksを使えばAPIリクエストまわり実装できる。

    たとえば以下のような実装だ。

    function useUser(id: string) {
      const [user, setUser] = useState<User | null>(null)
      const [isPending, setIsPending] = useState(true)
    
      const fetchUser = async (id: string) => {
        try {
          setIsPending(true)
          const res = await client.get(`/api/users/${id}`)
    
          setUser(res.data)
        } finally {
          setIsPending(false)
        }
      }
    
      useEffect(() => {
        fetchUser(id)
      }, [id])
    
      return { data: user, isPending }
    }
    
    // コンポーネント
    
    const user = useUser("123456")
    
    if (user.isPending) {
      return <div>Loading...</div>
    }
    
    return (
      // type user.data = User | null
      <h1>ようこそ、{user.data.name!}さん</h1>
    )

    なぜTanStack Queryを使うのか?

    アプリケーションの規模が大きくなると、単純なデータ取得だけではなく、ローディングやキャッシュの管理、状態管理などが煩雑になる。TanStack Queryを使うことで、そういった面倒くさい状態管理をライブラリに任せられる。

    const { data: user, isPending } = useQuery(
      queryKey: ['user', id],
      queryFn: () => client.get(`/api/users/${id}`),
    )
    
    // コンポーネント
    if (isPending) {
      return <div>Loading...</div>
    }
    
    return (
      // type user = User
      <h1>ようこそ、{user.name}さん</h1>
    )

    この記事では、TanStack Queryの全体像を理解しながら、基本的な使い方を紹介する。

    TanStack Queryの全体像

    TanStack Queryが提供するAPIは、主に以下のとおり。ただし、すべて理解しなくても重要な部分だけを使えば、十分に便利に使える。

    基本

    API用途
    useQueryサーバーからデータを取得する
    useMutationサーバー上のデータを作成・更新・削除する
    useQueryClientQuery Clientを取得し、キャッシュを操作する
    useQueries複数のQueryをまとめて実行する
    useSuspenseQueryReact Suspenseと組み合わせてデータを取得する

    Queryの再取得やキャッシュ操作

    useQueryClient()から取得したclientに対して使う。よく使うAPIは以下のとおり。

    メソッド用途
    invalidateQueriesQueryを「古い」とマークし、再取得を促す
    setQueriesData複数Queryのキャッシュを更新する
    removeQueriesQueryをキャッシュから削除する

    Mutation関連

    API用途
    useMutationデータの作成・更新・削除
    mutateMutationを実行する
    mutateAsyncMutationをPromiseとして実行する
    resetMutationの状態をリセットする

    状態確認

    API用途
    useIsFetching現在実行中のQuery数を取得する
    useIsMutating現在実行中のMutation数を取得する

    useQuery: APIからデータを取得する

    useQueryメソッドを使うことで、データ取得の管理ができる。

    /**
     * APIリクエストの実装例
     */
    async function fetchUsers(): Promise<User[]> {
      const res = await client.get('/api/users')
      return res.data
    }
    
    /**
     * TanStack Queryのキー
     * NOTE: Queryを識別するためのキーで、同じqueryKeyを使うとキャッシュが共有される。
     */
    const UserKeys = {
      all: ['users'] as const,
      detail: (userId: string) => [...UserKeys.all, userId] as const,
      status: (userId: string) => [...UserKeys.detail(userId), 'status'] as const,
    }
    
    /**
     * ユーザー一覧を取得するQuery
     */
    function useUsersQuery() {
      return useQuery({
        queryKey: UserKeys.all,
        queryFn: fetchUsers,
      })
    }
    
    /**
     * ユーザーを取得するQuery
     */
    function useUserQuery(userId: string) {
      return useQuery({
        queryKey: UserKeys.detail(userId),
        queryFn: () => fetchUser(userId),
      })
    }
    // Queryの利用
    const query = useUsersQuery()
    
    if (query.isSuccess) {
      return (
        <ul>
          {query.data.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )
    }

    キャッシュの仕組み

    useQueryで紹介したqueryKeyをもとに、取得したデータはキャッシュされ管理されている。そのため、別コンポーネントで同じQueryを利用した場合は、既存のキャッシュを利用できる。

    ただし、キャッシュされたデータが古くなっている場合は、再取得する必要がある。

    useQuery#staleTime

    staleTimeはキャッシュされたデータが「古い」とみなされるまでの時間を指定する。

    たとえば、staleTime: 1000 * 60 * 5と指定した場合は、5分間はFreshな状態として扱われる。

    useQuery({
      queryKey: UserKeys.all,
      queryFn: fetchUsers,
      staleTime: 1000 * 60 * 5, // 5 minutes
    })

    デフォルトでは、0ミリ秒が指定されており、取得したデータはすぐにStaleとして扱われる。重要なのは「即座に削除される」という意味ではなく、「古くなったため、再取得できる状態」を意味している。

    Stale状態のデータは以下のようなタイミングで再取得される可能性がある。

    useQuery#gcTime

    gcTimeは利用されていないキャッシュを保持する時間を指定する。

    たとえば、あるコンポーネントがアンマウントされると、その中で利用していたQueryを参照するObserverがいなくなる。その際、Queryは無効になり、gcTimeで指定された時間まではキャッシュを保持し続け、時間が過ぎるとキャッシュから削除される。

    useQuery({
      queryKey: UserKeys.all,
      queryFn: fetchUsers,
      gcTime: 1000 * 60 * 10, // 10 minutes
    })

    データの再取得

    staleTimegcTimeを指定することで、自動的にキャッシュの有効期限を管理できるが、その時間に関わらずrefetch()メソッドを使うと手動でデータの再取得ができる。

    const { data, refetch } = useQuery({ ... })
    
    return (
      <button onClick={() => refetch()}>
        データを再取得する
      </button>
    )

    基本的にデータの再取得は手動で管理する必要はない。invalidateQueries()メソッドを使うことで、データを無効化し、次回の取得時に再取得させることができる。この仕組みは、後述するuseMutation()などを使ったデータ更新後に特に重要になる。

    const queryClient = useQueryClient()
    
    queryClient.invalidateQueries({ queryKey: UserKeys.all })

    useQueries: 複数のQueryをまとめて実行する

    useQueriesを使うことで、複数のQueryをまとめて実行できる。

    たとえば、複数のIDを使ってそれぞれユーザーの詳細情報を取得する場合は、以下のように実装できる。

    function useUserQueries(userIDs: string[]) {
      return useQueries({
        queries: userIDs.map((userID) => ({
          queryKey: UserKeys.detail(userID),
          queryFn: () => fetchUser(userID),
        })),
      })
    }
    
    const queries = useUserQueries(['100001', '100002', '100003'])
    
    queries.map(q => {
      if (q.isPending) {
        return <div>Loading...</div>
      }
    
      return <div>{q.data.name}</div>
    })

    useMutation: データの作成・更新・削除

    useMutationメソッドを使うことで、データの作成・更新・削除の管理ができる。

    作成や更新、削除を行った場合はキャッシュされているデータが古くなるため、invalidateQueries()メソッドを使ってキャッシュを無効化する。

    //denaito

    Createリクエスト

    function useCreateUserMutation() {
      const queryClient = useQueryClient()
    
      return useMutation({
        mutationFn: (user: User) => client.post('/api/users', user),
        onSuccess: () => {
          // データ作成後にQueryを無効化し、再取得させる
          queryClient.invalidateQueries({ queryKey: UserKeys.all })
        },
        onError: (error) => {
          // エラーハンドリング
        },
      })
    }
    // Mutationの利用
    const mutation = useCreateUserMutation()
    
    function handleClick() {
      mutation.mutate({
        name: '新しいユーザー',
        email: 'newuser@example.com',
      })
    }

    Update/Deleteリクエスト

    function useUpdateUserMutation() {
      const queryClient = useQueryClient()
    
      return useMutation({
        mutationFn: (user: User) => client.put(`/api/users/${user.id}`, user),
        onSuccess: () => {
          // mutationの場合、キャッシュされたデータが自動で更新されないのでQueryを無効化し、再取得させる
          queryClient.invalidateQueries({ queryKey: UserKeys.all })
          queryClient.invalidateQueries({ queryKey: UserKeys.detail(user.id) })
        },
        onError: (error) => {
          // エラーハンドリング
        },
      })
    }
    // Mutationの利用
    const mutation = useUpdateUserMutation()
    
    async function handleClick() {
      try {
        const res = await mutation.mutateAsync({
          id: '123456',
          name: '更新されたユーザー',
          email: 'updateduser@example.com',
        })
      } catch (e) {
        // エラーハンドリング
      }
    }

    QueryClientによるキャッシュ操作

    TanStack Queryでは、QueryClientがQuery Cacheを管理している。useQueryClient()を使うことで、QueryClientを取得できる。

    import { useQueryClient } from '@tanstack/react-query'
    const queryClient = useQueryClient()

    invalidateQueries: Queryを古い状態にする

    以下のようなQueryがあるとする。

    よく使われるシーンは、前章でも説明したとおりMutationが成功したあとだ。invalidateQueries()を実行することで、次回users:100001のデータを取得しようとしたときに、Queryが古いと判断され、新しいデータを取得することができる。

    queryClient.invalidateQueries({ queryKey: ['users'] })
    
    try {
      await mutation.mutateAsync({
        id: '100001',
        name: '更新されたユーザー',
        email: 'updateduser@example.com'
      })
    
      queryClient.invalidateQueries({ queryKey: ['users', '100001'] })
    }

    setQueryData: キャッシュを直接更新する

    invalidateQueries()は、Queryを「古い」とマークするだけで、キャッシュには古いデータが残っている。更新するにはAPIリクエストなどが必要だ。

    他方、setQueryData()は、キャッシュを直接更新するため、更新したデータを即座時反映できる。サーバーの変更結果がすでにわかっているときに有効だ。

    const newUser = await mutation.mutateAsync({
      id: '100001',
      name: '更新されたユーザー',
      email: 'updateduser@example.com'
    })
    
    queryClient.setQueryData(['users', '100001'], newUser)

    removeQueries: Queryをキャッシュから削除する

    removeQueries()は、指定したQueryをキャッシュから削除する。たとえば、ログアウト時にユーザー情報を削除したい場合などに有効だ。

    queryClient.removeQueries({
      queryKey: UserKeys.all,
    })

    データが変更された場合はinvalidateQueries()、サーバーから新しいデータが返ってきた場合はsetQueryData()、キャッシュそのものを削除したい場合はremoveQueries()のように使い分ける。

    React Suspense/ErrorBoundaryとの連携

    useQueryを使う場合

    useQueryでは、コンポーネント側でロード状態の処理を行う。

    const { data, isPending, error } = useQuery({ ... })
    
    if (isPending) {
      return <Loader />
    }
    
    if (error) {
      return <Error />
    }
    
    return <div>{data}</div>

    useSuspenseQueryを使う場合

    useSuspenseQueryを使うことで、React Suspenseの仕組みを利用できる。

    function MyComponent() {
      const { data } = useSuspenseQuery({ ... })
    
      // コンポーネント内ではLoading/Errorの状態を直接扱う必要はない
      return <div>{data}</div>
    }
    function DataBoundary({ children }: FC<PropsWithChildren<Props>>) {
      return (
        <ErrorBoundary fallback={<Error />}>
          <Suspense fallback={<Loader />}>
            {children}
          </Suspense>
        </ErrorBoundary>
      )
    }
    
    <DataBoundary>
      <MyComponent />
    </DataBoundary>

    useSuspenseQueryでデータ取得イベントが発生した場合、Suspenseの仕組みでLoadingコンポーネントが表示される。

    また、エラーが発生した場合は、ErrorBoundaryの仕組みでErrorコンポーネントが表示される。