Open6

react18環境下でのSuspense

onoshionoshi

とりあえず環境構築
今回はフロント挙動だけ確認したいのでViteで雑に作る
yarn create vite . --template react-swc-ts

package.json
{
  "name": "react18-sandbox",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.56",
    "@types/react-dom": "^18.2.19",
    "@typescript-eslint/eslint-plugin": "^7.0.2",
    "@typescript-eslint/parser": "^7.0.2",
    "@vitejs/plugin-react-swc": "^3.5.0",
    "eslint": "^8.56.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.5",
    "typescript": "^5.2.2",
    "vite": "^5.1.4"
  }
}
onoshionoshi

react17では例えばこんな感じに書いていた(Fetch-on-render)

App.tsx
import { useState, useEffect } from 'react';
import './App.css'

const sleep = (ms: number) => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

export default function App() {
  const [userName, setUserName] = useState('');
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    sleep(2000).then(() => { // データ取得待ちを擬似的に再現
      setUserName('Taro')    // データ取得が完了したらユーザー名をセット
    }).finally(() => {
      setIsLoading(false);
    });
  }, []);

  return (
    <div>
      {
        isLoading ? (
          <h1>Loading...</h1>
        ) : (
          <h1>{userName}</h1>
        )
      }
    </div>
  );
}
onoshionoshi

react18でSuspenseを利用してベタっと書くとこんな感じ

App.tsx
import { Suspense } from 'react';
import './App.css'

const sleep = (ms: number) => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

const fetchUserName = () => {
  let status = 'pending';
  let result = '';
  const suspender = sleep(2000).then(() => {
    status = 'resolved';
    result = 'Taro';
  }).catch(e => {
    status = 'rejected';
    result = e;
  });

  return () => {
    if (status === 'pending') throw suspender;
    if (status === 'rejected') throw result;
    return result;
  };
};

const userName = fetchUserName(); // 非同期処理の状態を保持

// コンポーネントとして切り出される
const UserNameElement = () => {
  const name = userName();
  return <h1>{name}</h1>;
};

// App内の記述は簡潔になる
export default function App() {
  return (
    <div>
      <Suspense fallback={<h1>Loading...</h1>}>
        <UserNameElement />
      </Suspense>
    </div>
  );
}
onoshionoshi
  • (データ取得が終わっていて)UserNameElementがレンダリングできる場合はUserNameElementが表示される
  • (データ取得が終わっていなくて)UserNameElementがレンダリングできない場合はUserNameElementのレンダリングがサスペンド(中断)されてfallbackに指定した要素が表示される
    • つまりuserName()の戻りがPromiseである場合
    • この場合、reactが後から再レンダリングしようとする
      • 再レンダリング時にUserNameElementは毎回最初からレンダリングされるので、非同期処理の状態をコンポーネント外に持つ必要がある
        • const userName = fetchUserName(); のところ
onoshionoshi
  • fetchUserNameに相当する部分はある程度汎化して切り出したりSuspenseネイティブなライブラリを利用することになるイメージ
  • UserNameElementは単独のコンポーネントとして定義された(ので別ファイルに切り出せば良い)
  • App内では(データのローディングに関わる)state管理およびuseEffectの利用をなくすことができ、コンポーネントの取り回しだけを行う記述になった