Closed11

[キャッチアップ] React Query

shingo.sasakishingo.sasaki

スクラップの概要

React Query の世界観と基本的な使い方を勉強して、個人的なメモや感想を残していく

shingo.sasakishingo.sasaki

公式ドキュメントのトップページを眺める

React Query は、グローバルステートなしで、Reactアプリケーションで使用するデータの取得、更新、同期を行うためのライブラリ

https://react-query.tanstack.com/

宣言的で自動的

React Query に、データの取得方法と更新方法を教えるだけで、あとは自動で行ってくれるため、データ取得、更新のロジックを手動で管理する必要がなくなります。

シンプルで馴染み深い

Promise や async/await を知っていればすぐに馴染める使い方で、複雑な設定や、覚えるべき事柄、グローバルステートは不要で、データを取得する関数を渡すだけで済みます

強力かつ拡張可能

全てのユースケースに対応できるように、各オブザーバーインスタンスに、ノブ(?) とオプションの設定が可能です。開発ツールや無限ローディングAPI、データの更新を簡単にするファークスとクラスのミューテーションツールも付属しており、いずれも良い感じに初期設定されています。

Less Code. Fewer Edge Cases.

Reducerを書いて、キャッシュロジックを書いて、タイマー制御して、リトライロジック書いて、複雑な async/await コードを書いて…。なんてやる代わりに React Query を使うと、驚くほどコード量を削減することができます。

感想

正直これだけ読んでもどんなライブラリなのか全然イメージが沸かない。

なんとなーく、APIからのデータ取得とその管理をめちゃくちゃ簡便にしてくれるライブラリっていうイメージだけど、宣言的で自動的とは具体的にどんなものなのか、想像もつかない。

続けてチュートリアル的なのもやっていこうと思うけど、どこまで驚かされるか楽しみだ。

shingo.sasakishingo.sasaki

Overview

React Query はしばしば、React 用のデータフェッチングのためのライブラリだと言われるが、実際はフェッチングだけでなく、キャッシング及びサーバとの同期も行ってくれるライブラリだ。

開発動機

React 自体には、コンポーネントからデータフェッチしたり更新するための仕組みが提供されていないため、結局開発者それぞれが、独創的な方法を実施することになる(React Hooks 使うとか、もっと特化したライブラリに頼るとか)

成熟した状態管理ライブラリは、クライアントサイドの状態を管理する上では非常に優れているが、サーバサイドの状態を同期するのは困難である。

クライアントからみたサーバサイドの状態とは以下のようものだ

  • リソースのロケーションが分離されてるので、クライアントサイド自身で管理することができない
  • 非同期APIを叩いて更新するしかない
  • データの所有権が分散しており、知らないところで(他のユーザーなどが)いつのまにか更新してたりする
  • いつのまにか内容が古くなることもある

以上の特性を考慮すると、現代の状態管理ライブラリでは以下のような課題がある。

  • キャッシュの管理が難しい
  • データが古いという事実を知ることが難しい
  • サーバサイドのデータ更新を素早く行うのが難しい
  • 古いデータをバックグラウンドで更新するのが難しい
  • ページネーションや遅延読み込みのパフォーマンス最適化が難しい
  • メモリ管理やガーベジコレクトが難しい
  • クエリ結果のメモ化の共有が難しい

以上の課題に圧倒される人がほとんどだろうが、 React Query はこれらを解決する最良のライブラリだ。ゼロコンフィグで、すぐに利用可能かつ拡張可能なのに驚くほど機能する。

技術的な話をすると、React Query は以下のようなものだ

  • 既存の多くの複雑で可読性の低いコードをほとんど消し去ってくれる
  • 状態管理のためのコードを新たに書かずとも、メンテナンス性が高く、簡単に新機能を追加できるようなアプリケーションを作れるようになる
  • アプリケーションを利用するユーザーにとっても、より高速な体験を与えられる
  • 通信帯域やメモリ消費量を抑えてくれる可能性もある

感想

物凄い銀の弾丸に感じて眉唾もの。サーバサイドの状態と同期するのが難しいという課題感は日頃の開発業務でも感じてるけど、それを例えば Firebase みたいなサーバサイドの協力(websockerなど) なしで、クライアントサイドだけで解決できるとは到底思えない。

シンプルなサンプルコードも記載されているが、コンポーネント内で Hooks のような形でデータ取得をしてるように見えるだけで、まだまだ謎だ。

import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
 
 const queryClient = new QueryClient()
 
 export default function App() {
   return (
     <QueryClientProvider client={queryClient}>
       <Example />
     </QueryClientProvider>
   )
 }
 
 function Example() {
   const { isLoading, error, data } = useQuery('repoData', () =>
     fetch('https://api.github.com/repos/tannerlinsley/react-query').then(res =>
       res.json()
     )
   )
 
   if (isLoading) return 'Loading...'
 
   if (error) return 'An error has occurred: ' + error.message
 
   return (
     <div>
       <h1>{data.name}</h1>
       <p>{data.description}</p>
       <strong>👀 {data.subscribers_count}</strong>{' '}
       <strong>{data.stargazers_count}</strong>{' '}
       <strong>🍴 {data.forks_count}</strong>
     </div>
   )
 }
shingo.sasakishingo.sasaki

React プロジェクトの準備

ざっとドキュメント眺めると、特にハンズオン用のプロジェクトが用意されてるわけじゃなさそうなので、 create-react-app で適用に用意する。

$ npx create-react-app react-query-test
$ cd react-query-test/
$ yarn start

開発環境が立ち上がったことを確認

React Query のインストール

https://react-query.tanstack.com/installation

$ yarn add react-query

依存はこんな感じ。 React 16.8 以上なら良いらしい

package.json
  "dependencies": {
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "react": "^17.0.1",
    "react-dom": "^17.0.1",
    "react-query": "^3.5.6",
    "react-scripts": "4.0.1",
    "web-vitals": "^0.2.4"
  },

DevTool について

https://react-query.tanstack.com/devtools

ReactQuery には デバッグのための dev-tool が内包されてるらしい。
デフォルトだとプロダクション以外では有効化されるからガンガン使って行けとのこと。

まぁざっと読んでも今はよくわからんので飛ばす

shingo.sasakishingo.sasaki

例1: シンプル

https://react-query.tanstack.com/examples/simple

Github API を叩いて、リポジトリの情報を取得して表示するだけの例

import { useQuery, QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  );
}

function Example() {
  const { isLoading, error, data, isFetching } = useQuery("repoData", () =>
    fetch(
      "https://api.github.com/repos/tannerlinsley/react-query"
    ).then((res) => res.json())
  );

  if (isLoading) return "Loading...";

  if (error) return "An error has occurred: " + error.message;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{" "}
      <strong>{data.stargazers_count}</strong>{" "}
      <strong>🍴 {data.forks_count}</strong>
      <div>{isFetching ? "Updating..." : ""}</div>
      <ReactQueryDevtools initialIsOpen />
    </div>
  );
}

export default App;

  • QueryClient はプロバイダ経由で各コンポーネントに渡す(ここでは不使用だけど)
  • useQuery を用いて、データの取得方法を定義
  • 取得したデータ、エラー、各種フラグは Hooks によって自動で更新される

ということはなんとなくわかる。この時点で、 Promise 管理してフラグ制御したりする手間が省けるので、既に便利だなって感じはしてる。サーバサイドとの同期を手軽にってのはまだまだわからないけど。

あと devtool が ブラウザ拡張とかじゃなくて普通にインラインで描画されるのがビックリした。

shingo.sasakishingo.sasaki

例② 基本

コンテンツ一覧から、選択したコンテンツを動的に読み込んで描画する

https://react-query.tanstack.com/examples/basic

  • useQuery を使用することで、グローバルステートに状態を保持できる
  • 状態のキー (QueryKey) は、文字列(posts) や配列([post, post_id]) で設定可能
  • いずれの状態も QueryClientProvider から注入されるので勝手に使うことが出来る
import { useState } from "react";
import { useQuery, QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";
import axios from "axios";

const queryClient = new QueryClient();

function App() {
  const [postId, setPostId] = useState(-1);

  return (
    <QueryClientProvider client={queryClient}>
      <h1>コンテンツを選んでくれよな {postId}</h1>
      {postId === -1 ? (
        <Posts setPostId={setPostId} />
      ) : (
        <Post postId={postId} setPostId={setPostId} />
      )}
      <ReactQueryDevtools initialIsOpen />
    </QueryClientProvider>
  );
}

function Post({ postId, setPostId }) {
  const { status, data } = usePost(postId);

  return (
    <div>
      {status === "loading" ? (
        <span>Loading...</span>
      ) : (
        <div>
          <h2>{data.title}</h2>
          <a href="#" onClick={() => setPostId(-1)}>
            Back
          </a>
          <div>{data.body}</div>
        </div>
      )}
    </div>
  );
}

function Posts({ setPostId }) {
  const { status, data } = usePosts();
  return (
    <div>
      <h2>Posts</h2>
      <div>
        {status === "loading" ? (
          "Loading..."
        ) : status === "error" ? (
          "Errorr"
        ) : (
          <div>
            {data.map((post) => (
              <p key={post.id}>
                <a href="#" onClick={() => setPostId(post.id)}>
                  {post.title}
                </a>
              </p>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

function usePost(postId) {
  return useQuery(["post", postId], async () => {
    const { data } = await axios.get(
      `https://jsonplaceholder.typicode.com/posts/${postId}`
    );
    return data;
  });
}

function usePosts() {
  return useQuery("posts", async () => {
    const { data } = await axios.get(
      "https://jsonplaceholder.typicode.com/posts"
    );
    return data;
  });
}

export default App;

Query に関してもう少し深ぼったほうが良さそう

shingo.sasakishingo.sasaki

Queries

https://react-query.tanstack.com/guides/queries
https://react-query.tanstack.com/guides/query-keys
https://react-query.tanstack.com/guides/query-functions

  • Query は、宣言的に、非同期に依存するデータソースを定義する方法
  • Queryには Query Keys が必要
    • Query Keys は、単純な文字列から、配列、オブジェクトと言った複雑なデータ構造まで、いくつかの指定方法がある
    • とにかくシリアライズ可能で、かつユニークであれば何でも良い
  • Queryには、非同期でデータを取得する、Promiseベースのメソッドを定義する
    • フェッチ失敗時は必ず Error を throw する
  • データの取得ではなく、更新を行う場合は、 Mutaions を使うことを推奨する
  • コンポーネント(or カスタムHook) でQueryを購読するために useQuery を使う
function App() {
  const resource = useQuery('key', async () => {
    const res = await fetch(api_url)
    return res.json()
  })
}

useQuery は、フェッチに関わる諸々の情報を返す。データは Hooks のため、フェッチの状況に応じてリアクティブに更新される。

  • isLoading
  • isError
  • isSuccess
  • isIdle
  • error
  • data
  • isFetching
 function Todos() {
   const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList)
 
   if (isLoading) {
     return <span>Loading...</span>
   }
 
   if (isError) {
     return <span>Error: {error.message}</span>
   }
 
   // We can assume by this point that `isSuccess === true`
   return (
     <ul>
       {data.map(todo => (
         <li key={todo.id}>{todo.title}</li>
       ))}
     </ul>
   )
 }
shingo.sasakishingo.sasaki

例③ ポーリングによる自動更新

https://react-query.tanstack.com/examples/auto-refetching

本例では、TODOの一覧取得、追加、全削除のAPIを用意し、ブラウザからそれぞれを利用する。

APIの実装が必要なので、 Next.js を用いて、 まずはAPIルートを用意する。

pages/api/data.js
let list = ["Item 1", "Item 2", "Item 3"];

export default async (req, res) => {
  if (req.query.add) {
    if (!list.includes(req.query.add)) {
      list.push(req.query.add);
    }
  } else if (req.query.clear) {
    list = [];
  }

  await new Promise((r) => setTimeout(r, 1000));

  res.json(list);
};

フロントエンドでは、 useQuery を用いてTODO一覧の取得を行い、 useMutation を用いて、TODOの追加、全削除を行う。

pages/index.js
import React from "react";
import axios from "axios";
import {
  useQuery,
  useQueryClient,
  useMutation,
  QueryClient,
  QueryClientProvider,
} from "react-query";
import { ReactQueryDevtools } from "react-query/devtools";

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
      <ReactQueryDevtools initialIsOpen />
    </QueryClientProvider>
  );
}

function Example() {
  const queryClient = useQueryClient();

  const [intervalMs, setIntervalMs] = React.useState(1000);
  const [value, setValue] = React.useState("");

  const { status, data, error } = useQuery(
    "todos",
    async () => {
      const res = await axios.get("/api/data");
      return res.data;
    },
    {
      refetchInterval: intervalMs,
    }
  );

  const addMutation = useMutation((value) => fetch(`/api/data?add=${value}`), {
    onSuccess: () => queryClient.invalidateQueries("todos"),
  });

  const clearMutation = useMutation(() => fetch("/api/data?clear=1"), {
    onSuccess: () => queryClient.invalidateQueries("todos"),
  });

  if (status === "loading") return <h1>Loading...</h1>;
  if (status === "error") return <span>Error: {error.message}</span>;

  return (
    <div>
      <h1>Auto Refresh Sample</h1>
      <label>
        Query Interval Speed (ms):{" "}
        <input
          value={intervalMs}
          onChange={(ev) => setIntervalMs(Number(ev.target.value))}
          type="number"
          step="100"
        />
      </label>
      <h2>Todo List</h2>
      <form
        onSubmit={(event) => {
          event.preventDefault();
          addMutation.mutate(value, {
            onSuccess: () => {
              setValue("");
            },
          });
        }}
      >
        <input
          placeholder="enter new todo"
          value={value}
          onChange={(ev) => setValue(ev.target.value)}
        />
      </form>
      <ul>
        {data.map((todo) => (
          <li>{todo}</li>
        ))}
      </ul>
      <div>
        <button onClick={clearMutation.mutate}>Clear All</button>
      </div>
    </div>
  );
}
shingo.sasakishingo.sasaki

Mutations

https://react-query.tanstack.com/guides/mutations

Mutations は、 Query とは異なり、データの create/update/delete など、サーバサイドへの副作用をもたらす処理をuseMutation hook を利用して定義する

function NewTodoForm() {
  const [newTodo, setNewTodo] = useState('') 
  const mutataion = useMutation(newTodo => axios.post('/todo', newTodo))
  return (
    <form onSubmit={e => {
      e.preventDefault()
      mutation.mutate(newTodo)
    }}>
      <input
        value={newTodo}
        onChange={(e) => setNewTodo(e.target.value)}
      />
    <form>
  )
}

useMutation で返却された mutation を用いることで、mutate することができる

    <form onSubmit={e => {
      e.preventDefault()
      mutation.mutate(newTodo)
    }}>

Query 同様に、 mutation 内の以下属性を通じて、状態を取得することも可能

  • isIdle
  • isLoading
  • isError
  • isSuccess

mutation 成功時などのコールバックの定義も可能で、 invalidateQueries と組み合わせることで、データの最新化を促すことができる

  const addMutation = useMutation((value) => fetch(`/api/data?add=${value}`), {
    onSuccess: () => queryClient.invalidateQueries("todos"),
  });
shingo.sasakishingo.sasaki

終わりに

他のサンプルやリファレンスを見てみると、かなり色々なことが出来ると言うか、従来のアプリで手動で頑張ってたことを良い感じに隠蔽してくれるライブラリだと実感。

だいたいのユースケースはデフォルトの設定で対応できそうなので、実際にアプリを作る上でどんどん試していこう。

このスクラップは2020/12/30にクローズされました