【React】データの取得・保存・状態管理

こちらのReactハンズオンラーニングより学ばせていただいたことをまとめています。

書籍の説明

書籍名: Reactハンズオンラーニング 第2版 ―Webアプリケーション開発のベストプラクティス
著者: Alex Banks 著
出版社: オライリージャパン 発行
発売日: 2021/8/6

データの取得

実際に、GitHubのユーザー情報を取得し、描画するコンポーネントを作成しながら見ていきます。

import { useEffect, useState } from "react";

export const GitHubUser = ({ login }) => {
  const [data, setData] = useState();

  useEffect(() => {
    if (!login) return;
    fetch(`https://api.github.com/users/${login}`)
      .then((response) => response.json())
      .then(setData)
      .catch(console.error);
  }, [login]);

  if (data) return <pre>{JSON.stringify(data, null, 2)}</pre>;
  return null;
};

import { GitHubUser } from "./components/GitHubUser";

function App() {
  return <GitHubUser login="m0t0-taka" />;
}

export default App;

ポイントとなる行をハイライトしています。

描画の順番

こちらのコードがどのような順番で描画に至っているのかまとめました。

  1. App.jsのloginプロパティによりGitHubUserが描画される
  2. 初回描画時にはstateのdataが未取得なのでnullが返る
  3. 描画関数(if (data) return←この部分)が呼ばれた直後にuseEffectの副作用関数が実行されてfetchが呼び出される
  4. setDataでstateが更新されるためコンポーネントが再描画される
  5. このときにはdataに値が存在しているため、dataがtrueとなりコンポーネント描画される

ここでのポイント!

  • GitHubUserのレンダリング
    App.jsでのlogin呼び出しで一回のみ。
  • App.jsのレンダリング
    最初に一回目。
    子であるGitHubUserのstateが更新されるsetData実行時に二回目
  • useEffectの実行タイミング
    useEffect内は、初回描画時には実行されず、副作用で実行される。

これにより以下の表示画面となります(データ取得ができます)。

{
  "login": "m0t0-taka",
  "id": 81753585,
  "node_id": "MDQ6VXNlcjgxNzUzNTg1",
  "avatar_url": "https://avatars.githubusercontent.com/u/81753585?v=4",
  "gravatar_id": "",
  "url": "https://api.github.com/users/m0t0-taka",
  "html_url": "https://github.com/m0t0-taka",
  "followers_url": "https://api.github.com/users/m0t0-taka/followers",
  "following_url": "https://api.github.com/users/m0t0-taka/following{/other_user}",
  "gists_url": "https://api.github.com/users/m0t0-taka/gists{/gist_id}",
  "starred_url": "https://api.github.com/users/m0t0-taka/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/m0t0-taka/subscriptions",
  "organizations_url": "https://api.github.com/users/m0t0-taka/orgs",
  "repos_url": "https://api.github.com/users/m0t0-taka/repos",
  "events_url": "https://api.github.com/users/m0t0-taka/events{/privacy}",
  "received_events_url": "https://api.github.com/users/m0t0-taka/received_events",
  "type": "User",
  "site_admin": false,
  "name": null,
  "company": null,
  "blog": "",
  "location": null,
  "email": null,
  "hireable": null,
  "bio": null,
  "twitter_username": null,
  "public_repos": 76,
  "public_gists": 0,
  "followers": 7,
  "following": 9,
  "created_at": "2021-04-01T11:33:33Z",
  "updated_at": "2022-01-13T00:50:22Z"
}

JSON.stringifyの3つの引数の説明

JSON.stringifyが持つ、3つの引数はそれぞれ以下を意味しています。

  1. data:変換対象となるJSONオブジェクト
  2. null:変換に使用されるコールバック関数
  3. 2:スペースの数

スペースの数を2個とすることでインデントして表示されます。

これにより可読性を保った表示がされるようになっています。

データの保存

データの保存は、実はローカルストレージというローカルにすることもできます。

それには、window.localStorageまたはwindow.sessionStorageというオブジェクトを使用します。

この2つの違いは以下になります。

window.localStorage・・・明示的に削除されるまで保持される

window.sessionStorage・・・そのセッションの間だけ有効。そのため、ページを閉じるとデータは削除される

ローカルストレージに保存する際には、JSONオブジェクトの変換が必要になります。

データを書き込む前・・・JSON.stringifyを使って文字列に変換

const saveJSON = (key, data) =>
  localStorage.setItem(key, JSON.stringify(data))

データを読み出すとき・・・JSON.parseを使ってオブジェクトに復元

const loadJSON = key =>
  key && JSON.parse(lacalStorage.getItem(key))

ここで使用しているstringfyとparseは同期関数のため、loadJSONとsaveJSONも必然的に同期関数となります。

そのため、これらの関数を頻繁に呼び出すことはパフォーマンスの低下につながります。

loadJSONとsaveJSONを、上記のGitHubのデータをfetchするコードで使用して、ダウンロードしたデータをlocalStorageに保存されるようにします。

state値をlocalStorageに保存することにより、リクエストを送信しなくてもStorageから読み出したデータを描画できるようになります。

全体のコードがこちら

import { useEffect, useState } from "react";

const loadJSON = (key) => key && JSON.parse(localStorage.getItem(key));

const saveJSON = (key, data) => localStorage.setItem(key, JSON.stringify(data));

export const GitHubUser = ({ login }) => {
  const [data, setData] = useState(loadJSON(`user:${login}`));

  // データをlocalStorageに保存するために使用
  useEffect(() => {
    if (!data) return;
    // 新しいデータかどうかを確認
    if (data.login === login) return;
    const { name, avatar_url, location } = data;
    saveJSON(`user:${login}`, {
      name,
      login,
      avatar_url,
      location,
    });
  }, [data]);

  // GitHubからデータを取得するために使用
  useEffect(() => {
    if (!login) return;
    if (data && data.login === login) return;
    fetch(`https://api.github.com/users/${login}`)
      .then((response) => response.json())
      .then(setData)
      .catch(console.error);
  }, [login]);

  if (data) return <pre>{JSON.stringify(data, null, 2)}</pre>;
  return null;
};

localStorageの保存は、以下の通り、指定した4つの値となっていることを確認できました。

そして、この状態でページを一旦閉じて、アプリケーションをリロードすると、localStorageから読み出されたデータが描画されます。

非同期リクエストの状態管理(error, loadingの処理)

HTTPリクエストは3つの状態を持ちます。

・保留中(pending)

・成功(fulfilled)

・失敗(rejected)

先程のGitHubコードにこれらの状態を付け加え、保留中や失敗の状態をUIで表現します。

import { useEffect, useState } from "react";

const loadJSON = (key) => key && JSON.parse(localStorage.getItem(key));

export const GitHubUser = ({ login }) => {
  const [data, setData] = useState(loadJSON(`user:${login}`));
  const [error, setError] = useState();
  const [loading, setLoading] = useState(false);

  // GitHubからデータを取得するために使用
  useEffect(() => {
    if (!login) return;
    setLoading(true);
    fetch(`https://api.github.com/users/${login}`)
      .then((data) => data.json())
      .then(setData)
      .then(() => setLoading(false))
      .catch(console.error);
  }, [login]);

  if (error) return <pre>{JSON.stringify(data, null, 2)}</pre>;
  if (loading) return <h1>loading...</h1>;
  if (!data) return null;

  return (
    <div>
      <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
};

変更箇所

・errorのstateを追加

・loadingのstateを追加

・errorとloadingのときのreturn値を設定

これにより表示される画面がこちら。

GitHubからの取得情報を表示できていますね!

RenderProp

描画のために使用するプロパティRenderPropについて見ていきます。

RenderPropは、コンポーネントの描画内容をプロパティ経由で他のコンポーネントに渡すことができます。

これを使用することで、描画の詳細をRenderPropに抽象化し、コンポーネントの再利用性を高めることができます。

例えば以下の例

const tahoe_peaks = [
  { name: "Feel Peak", elevation: 10891 },
  { name: "Monument Peak", elevation: 10067 },
  { name: "Pyramid Peak", elevation: 9983 },
];

function App() {
  return (
    <ul>
      {tahoe_peaks.map((peak, i) => (
        <li key={i}>
          {peak.name} - {peak.elevation.toLocaleString()}ft
        </li>
      ))}
    </ul>
  );
}

export default App;

こちらはRenderProp適用前です。

ここにRenderPropを適用し、ul要素の部分を抽象化していきます。

import { List } from "./components/List";

const tahoe_peaks = [
  { name: "Feel Peak", elevation: 10891 },
  { name: "Monument Peak", elevation: 10067 },
  { name: "Pyramid Peak", elevation: 9983 },
];

function App() {
  return (
    <List
      // 描画対象となる配列
      data={tahoe_peaks}
      // 配列が空の場合に描画
      renderEmpty={<p>This list is empty</p>}
      // 描画内容を指定する
      renderItem={(item) => (
        <>
          {item.name} - {item.elevation.toLocaleString()}
        </>
      )}
    />
  );
}

export default App;

export function List({ data = [], renderItem, renderEmpty }) {
  return !data.length ? (
    renderEmpty
  ) : (
    <ul>
      {data.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

Listコンポーネント部で、tahoe_peaksを呼び出すのでなく、renderItemに抽象化することで、この部分を再利用可能なコンポーネントとすることができています。

描画するコンポーネントをtahoe_peaksに限定せず、他のコンポーネントからも再利用できるようになりました。

当然、どちらも表示される画面はこちらに変わりはありません。

仮想リスト

上記の例ではリストの数が3件でしたが、例えばGoogle検索のような、リスト数が無限とも思えるくらい存在する場合は、その全てを一画面に表示することができません。

そこで、使われるテクニックが、仮想リスト(ウィンドウリストと呼ぶこともある)になります。

Reactではreact-widowとreact-virtualizedというものが提供されています。

それでは、使ってみます。

まずはダミーデータをつくります。

$ npm i faker@5.5.3

最新verをinstallすると、以下のエラーとなりましたので、書籍と同じ4.1.0のverを指定してinstallします。

Module not found: Error: Can’t resolve ‘faker’

しかし、@4.1.0では画像が表示されなかったので、こちらのstackoverflowをもとに、5.5.3をinstallすることにしました。

cannot find module faker after npm install --save-dev
I want to install all my modules locally so I am installing everything with the "--save-dev" switch which updates the package.json. I am trying to include this...

そして、installしたfakerのデータを表示します。

import faker from "faker";

import { List } from "./components/List";

const bigList = [...Array(100)].map(() => ({
  name: faker.name.findName(),
  email: faker.internet.email(),
  avatar: faker.random.image(),
}));

function App() {
  const renderItem = (item) => (
    <div style={{ display: "flex" }}>
      <img src={item.avatar} alt={item.name} width={100} />
      <p>
        {item.name} - {item.email}
      </p>
    </div>
  );
  return (
    <List
      data={bigList}
      renderItem={renderItem}
    />
  );
}

export default App;

ListコンポーネントはrenderPropsを使って抽象化しています。

export function List({ data = [], renderEmpty, renderItem }) {
  return !data.length ? (
    renderEmpty
  ) : (
    <ul>
      {data.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

以下の画面表示をすることができました。

それでは、react-windowを使って描画していきます。

$ npm i react-window

react-windowの提供する仮想リストコンポーネントにはFixedSizeListを使うこととします。

import { FixedSizeList } from "react-window";
import faker from "faker";

const bigList = [...Array(1000)].map(() => ({
  name: faker.name.findName(),
  email: faker.internet.email(),
  avatar: faker.random.image(),
}));

function App() {
  const renderRow = ({ index, style }) => (
    <div style={{ ...style, ...{ display: "flex" } }}>
      <img src={bigList[index].avatar} alt={bigList[index].name} width={100} />
      <p>
        {bigList[index].name} - {bigList[index].email}
      </p>
    </div>
  );
  return (
    <FixedSizeList
      height={window.innerHeight}
      width={window.innerWidth}
      itemCount={bigList.length}
      itemSize={50}
    >
      {/* RenderPropの関数はプロパティでなく子要素として渡す */}
      {renderRow}
    </FixedSizeList>
  );
}

export default App;

このFixedSizeListを使用すると、必要な数だけしか描画されないようになります。

そのため、画面に表示される情報は同じですが、必要な数だけしか描画されないので、描画に要す時間が短くなりました。

FixedSizeListは、スクロール操作を行う度に、既存のuserを描画から削除して、新しいuserを描画します。

useFetchフック

fetch APIを使ってリクエストを送信すると、”保留中””成功””失敗”の3つの状態を管理するコードを繰り返し記述することになります。

そこで、ここでは、3つの状態管理を再利用可能とするためにカスタムフックを作ります!

3つの状態を返すカスタムフックがこちら。

import { useState, useEffect } from "react";

export function useFetch(uri) {
  const [data, setData] = useState();
  const [error, setError] = useState();
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!uri) return;
    setLoading(true);
    fetch(uri)
      .then((data) => data.json())
      .then(setData)
      .then(() => setLoading(false))
      .catch(setError);
  }, [uri]);

  return loading, data, error;
}

このカスタムフックを、上記で作成したGitHubUserコンポーネントで使用してみます。

カスタムフック使用前

import { useEffect, useState } from "react";

const loadJSON = (key) => key && JSON.parse(localStorage.getItem(key));

export const GitHubUser = ({ login }) => {
  const [data, setData] = useState(loadJSON(`user:${login}`));
  const [error, setError] = useState();
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!login) return;
    setLoading(true);
    fetch(`https://api.github.com/users/${login}`)
      .then((data) => data.json())
      .then(setData)
      .then(() => setLoading(false))
      .catch(console.error);
  }, [login]);

  if (error) return <pre>{JSON.stringify(data, null, 2)}</pre>;
  if (loading) return <h1>loading...</h1>;
  if (!data) return null;
  
  return (
    <div>
      <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
};

カスタムフック使用後

useFetchの戻り値であるloading, data, errorのいずれかの値が変更されるたびにGitHubUserコンポーネントは再描画されます。

import { useFetch } from "../hooks/useFetch";

export const GitHubUser = ({ login }) => {
  const { loading, data, error } = useFetch(
    `https://api.github.com/users/${login}`
  );

  if (error) return <pre>{JSON.stringify(data, null, 2)}</pre>;
  if (loading) return <h1>loading...</h1>;

  return (
    <div>
      <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
};

これにより、3つの状態管理を再利用可能なフックとすることができました。

Fetchコンポーネント

これまでのカスタムフックはデータの共通化を目的としていましたが、ロード中のアイコンや、エラーの処理といったUIはアプリ内で共通としたいものです。

これらUIを共通化するコンポーネントについて見ていきましょう!

import { useFetch } from "../hooks/useFetch";

function Fetch({
  uri,
  renderSuccess,
  loadingFallback = <p>loading...</p>,
  renderError = (error) => <pre>{JSON.stringify(error, null, 2)}</pre>,
}) {
  const { loading, data, error } = useFetch(uri);
  if (error) return renderError(error);
  if (loading) return loadingFallback;
  if (data) return renderSuccess({ data });
}

ここで作成したFetchコンポーネントは、GitHubUserとuseFetchフックの間に位置し、抽象化レイヤーを提供します。

どういうことかというと、、、

・useFetchはfetchリクエストを抽象化するレイヤー

・Fetchコンポーネントは描画を抽象化するレイヤー

このFetchコンポーネントを使ってGitHubUserをさらに書き直します。

import { Fetch } from "./Fetch";

export const GitHubUser = ({ login }) => {
  return (
    <Fetch
      uri={`https://api.github.com/users/${login}`}
      renderSuccess={UserDetails}
    />
  );
};

function UserDetails({ data }) {
  return (
    <div>
      <img src={data.avatar_url} alt={data.login} style={{ width: 200 }} />
      <div>
        <h1>{data.login}</h1>
        {data.name && <p>{data.name}</p>}
        {data.location && <p>{data.location}</p>}
      </div>
    </div>
  );
}

変更箇所

・importを、useFetch → Fetchに変更

・描画部のコンポーネントを分けUserDetailsとし、リクエストが成功した場合に描画される

GraphQL

GraphQLを使うことで、異なるAPIを個別に呼び出す必要はなく、1回のAPI呼び出しで必要なデータを全て取得することができる。

GraphQLの特徴は、リクエストのたびに特別なクエリを送信することです。

GraphQLのクエリは、どんなデータが欲しいかを宣言的に記述。

受け取り側は、要求されたデータを単一のレスポンスとして返す。

クエリは、リクエストとして単一のGraphQLのエンドポイントに送信されます。

GraphQLを使ってみる

GraphQLのリクエストの送受信をするのにgraphql-requestを使います。

まずはgraphqlとgraphql-requestをinstall

$ npm i graphql graphql-request

GraphQLリクエストを送信するコードを作成します。

import { GraphQLClient } from "graphql-request";

const query = `
  query findRepos($login: String!){
    user(login: $login) {
      login
      name
      location
      avatar_url: avatarUrl
      repositories(first: 100) {
        totalCount
        nodes {
          name
        }
      }
    }
  }
`;

// GraphQLリクエストを送信するためのオブジェクト
const client = new GraphQLClient("https://api.github.com/graphql", {
  headers: {
    // トークンをHTTPリクエストに付与
    Authorization: `Bearer <PERSONAL ACCESS TOKEN>`,
  },
});

client
  .then(query, { login: "moontahoe" })
  .then((results) => JSON.stringify(results, null, 2))
  .then(console.log)
  .catch(console.error);

GitHubのユーザー情報をGraphQLで取得することができました。

以上、お読みいただきありがとうございました。

この章は少々難しかったため、経験値を積んで見直したいと思います。

コメント