OITA: Oika's Information Technological Activities

@oika 情報技術的活動日誌。

react-query : クラスコンポーネントのコードで導入するいくつかの方法

引き続き React Query のターン。

useQuery か、なるほどいいねこれということで導入しようと思っても、手元のコードは全然 class component ですよっていう場合に、どうやって導入していく方法があるかという整理。

おおよそそのまま、クラスコンポーネントでhooks使うにはどうするかっていう話になります。

検証コード

例として、以下のようなシンプルなコードを考える。

  • 外部リソースとしてユーザ一覧を管理している
  • 画面からユーザの追加を行う
  • 画面でユーザ一覧を表示する

f:id:kd1:20211003190352p:plain

検証用コードとして、今は以下のようなダミーのストアを作っておきます。
本来はこの addUser , fetchUserList から APIへのリクエストを投げているイメージ。
(以下、サンプルコードは TypeScript です)

export type User = { name: string };

// 本来はAPIからもらってくるデータ
let userList: User[] = [
  { name: "Yamada" },
  { name: "Sato" },
  { name: "Suzuki" }
];

/**
 * ユーザ追加APIへリクエストを送信する
 */
export const addUser = async (name: string) => {
  userList = [...userList, { name }];
  return Promise.resolve();
};

/**
 * ユーザ一覧取得APIからユーザリストを取得する
 */
export const fetchUserList = async () => {
  // 2秒くらいかかるとする
  await new Promise((resolve) => setTimeout(resolve, 2000));

  // 便宜的にメモリから取得
  return Promise.resolve(userList);
};

参考までに、ユーザ追加用の入力コンポーネントの実装イメージ。
こっちはまあ関数コンポーネントってことにしておく。

export const UserInput = () => {
  const client = useQueryClient();
  const [name, setName] = useState("");

  const onAdd = async () => {
    await addUser(name);
    setName("");

    // 今回は React Query の mutation は使わず、clientインスタンスからinvalidateQueries
    client.invalidateQueries("user-list");
  };

  return (
    <div>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button onClick={onAdd}>add</button>
    </div>
  );
};

ここからが本題で、このユーザ一覧を表示する画面がクラスコンポーネントになってたとして、useQueryするにはどうするかという話ですね。

方法1:関数コンポーネントへの移行

関数コンポーネントで書きなおすハードルが高くないなら、書きなおしてしまうのが正攻法。

全体を書き直すのが厳しい場合は、queryを利用する部分だけをうまく子コンポーネントとして切り出すことを考える。

export const UserListFC = () => {
  const { data, isError, isLoading } = useQuery("user-list", fetchUserList);

  if (isLoading) return <div>Loading...</div>;
  if (isError || data == null) return <div>ERR!</div>;
  return (
    <ul>
      {data.map((user, i) => (
        //※ほんとは index を key にしないほうがいい
        <li key={i}>{user.name}</li>
      ))}
    </ul>
  );
};

方法2:高階コンポーネント

HOC(Higher-Order Components)というやつ。
既存のクラスコンポーネントを丸ごとラップして、propsで取得データを受け渡す関数コンポーネントを設ける。

class UserList extends React.Component<{ userList: User[] }, {}> {
  render() {
    return (
      <ul>
        {this.props.userList.map((user, i) => (
          <li key={i}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

/**
 * 高階コンポーネント
 */
export const UserListHoc = () => {
  const { data, isLoading, isError } = useQuery("user-list", fetchUserList);

  if (isLoading) return <div>Loading...</div>;
  if (isError || data == null) return <div>ERR!</div>;

  return <UserList userList={data} />;
};

Render Props

HOCと似た方法で、Render Props というのがある。
こちらはpropsとしてレンダリング関数を受け取るコンポーネントを作るという実装パターンで、クラスコンポーネントのrenderの中に useQuery を使う関数コンポーネントを挟み込むような形にすることができる。

interface Props {
  children: (result: UseQueryResult<User[]>) => ReactElement;
}

/**
 * render props 関数コンポーネント
 */
const WithUserList = (props: Props) => {
  return props.children(useQuery("user-list", fetchUserList));
};

export class UserListRenderProps extends React.Component<{},{}> {
  render() {
    return (
      <WithUserList>
        {({ data, isError, isLoading }) => {
          if (isLoading) return <div>Loading...</div>;
          if (isError || data == null) return <div>ERR!</div>;

          return (
            <ul>
              {data.map((user, i) => (
                <li key={i}>{user.name}</li>
              ))}
            </ul>
          );
        }}
      </WithUserList>
    );
  }
}

方法3:queryClientを直接操作する

queryClient のインスタンスに対して、 fetchQuery を直接呼ぶこともできる。

何回も同じAPIでデータ取得しているような箇所をいい感じにキャッシュさせるだけの目的であれば、これが一番既存コードへの影響が少ない導入方法かもしれない。

難点は、invalidate によって refetch されるデータを画面に即時反映にするには、自分で再度 fetchQuery しなければならないこと。

// useClientなしで参照できるようにexportしておく
export const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div>
        <UserInput />
        <UserListDirectClient />
      </div>
    </QueryClientProvider>
  );
}
interface State {
  fetchStatus: "loading" | "error" | "done";
  userList: User[];
}
interface Props {}

export class UserListDirectClient extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);

    this.state = {
      fetchStatus: "loading",
      userList: []
    };
  }

  async componentDidMount() {
    try {
      //※componentDidMountでしか取得していないので、ユーザの追加は即時反映されない
      const list = await queryClient.fetchQuery("user-list", fetchUserList);
      this.setState({
        userList: list,
        fetchStatus: "done"
      });
    } catch {
      this.setState({ fetchStatus: "error" });
    }
  }

  render() {
    if (this.state.fetchStatus === "loading") {
      return <div>Loading...</div>;
    }
    if (this.state.fetchStatus === "error") {
      return <div>ERR!</div>;
    }

    return (
      <ul>
        {this.state.userList.map((user, i) => (
          <li key={i}>{user.name}</li>
        ))}
      </ul>
    );
  }
}

以上

ソース全体はこちら。