logo

React18について理解する

2022-11-10
3 years ago

開発環境

  • React 18.2.0

前提

当記事はReact公式ブログのReact v18.0を読んで、理解がいまいち・・という方におすすめです。

本題

まだまだお仕事ではバージョン17を使っております。おほじです。

バージョン18がリリースされてから公式ブログはみていましたが、難しい表現や想像しにくい部分がたくさんあったので実際に手を動かしながら整理してみたいと思います。

また、公式ブログでも以下のようなコメントがあったので、今後への期待感もありつつ、今回のアップデートはしっかりおさえていくことが今後のフロントエンド開発を理解していく入り口になるのではと思っております!

並行レンダーは React における新しいパワフルなツールであり、サスペンス、トランジション、ストリーミング付きサーバーレンダリングといった新たな機能のほとんどはこれを活用して構築されています。しかし React 18 はこの新しい基盤の上に我々が構築しようとしているものの始まりに過ぎません。

追加された機能

  • StrictMode
  • Automatic Batching
  • Transition
  • Suspense

StrictMode

開発時のみ、<React.StrictMode>でラップしている配下のコンポーネントで有効になります。

また、index.tsxの記述がバージョン17と比較すると微妙に変わっており、createRootAPIからrootを生成していないとバージョンが18でもStrictModeが有効にならず、17の振る舞いをするようです。

背景としては、今後Reactで導入されるオフスクリーンが実現するコンポーネントの表示、非表示、stateの再利用に現段階から耐えうる設計にするために導入されているようです。

挙動としては今まで一度しか実行されていなかったレンダリングが2度実行されることになります。

例えば、useEffectで依存配列を[]にした状態だとバージョン17までは初回のみレンダリングされていましたが、2度実行されることになります。

この挙動に耐えうる設計を求められるということですね。

ですので、2度実行されたことで挙動が変わってしまうような設計になっていれば見直していく必要があります。

Automatic Batching

おさらいとしてこれまでの挙動ではイベントハンドラ内でいくつset関数を実行しても一括で実行されていました。

このおかげで特に意識せずとも、パフォーマンスを落とさずに複数の処理をまとめて行いことができていました。

const onClickUpdateStates = () => {
	setState1((prev) => prev + 1)
	setState2((prev) => prev + 1)
	setState3((prev) => prev + 1)
}

# 再レンダリングは一度だけ

しかし、Promiseから実行されている場合など、その他のイベントでははまとめてくれず、都度レンダリングが起きていました。

const onClickFetchApi = async () => {
    try {
      const res = await fetch('https://xxxxxx');
      const data = await res.json();
      setTodos(data);
      setIsFinished(true);
    } catch (err) {
      throw new Error('fetch error');
    }
  };

# 2回レンダリングが実行される


バージョン18からはそれらの更新も含めて一括更新してくれるようになりました。

特に実装が必要というわけではなく、よしなにやってくれるので嬉しいアップデートですね。

Transition

公式が言うように緊急性の高い更新に時間かかるとユーザー体験が著しく下がりますよね。

ボタン押してから1~2秒間があると操作できているのか不安になりますよね。

逆にボタンを押してから「押したことはすぐ分かる」けれど、「押したことによる結果の表示」は多少時間がかかっても大きな問題にはならないはずです。

ざっくりした言い方をすると、それを解決するためにstate更新に優先順位づけをする機能がTransitionです。

使い方は非常にシンプルです。

緊急性の高くないstate更新をstartTransitionのコールバックに指定するだけでOKです。

import { useState, startTransition } from 'react';

const onClickHoge1Hoge2 = () => {
    setHoge1("hoge1");
    startTransition(() => {
      setHoge2("hoge2");
    });
  };

# 緊急性の高い更新 → hoge1
# 緊急性の低い更新 → hoge2

優先度の高い更新と低い更新の時間差が気になるケースもあると思いますが、そこもしっかりケアすることができます。

import { useTransition } from 'react';

const [isPending, startTransition] = useTransition();

取り出し時の変数名は自由に設定できますが、Hooksを利用すると第一引数にTransition処理間をbooleanで受け取ることが可能になります。

こちらをアプリケーションに合わせて見せ方を工夫すればユーザー体験を損ねない動きもできそうですね。

ただ、上記のようにset関数を利用している上位のコンポーネントであれば問題ないですが、必ずしもそうではないケースもあります。

例えば、大量のデータをpropsで受け取り表示させているだけの下位コンポーネントのパフォーマンス改善などそれにあたるかなと思います。

そう言う場合はuseDeferredValueを使用します。

使い方はこちらもとてもシンプルです。

import { FC, useDeferredValue } from 'react';

interface HogeListProps {
  hogeList: string[];
}

export const HogeLIst: FC<HogeListProps> = ({ hogeList }) => {
  const deferredHogeList = useDeferredValue(hogeList);
  return (
    <>
      {deferredHogeList.map((hoge, idx) => (
        <div key={idx}>
          <p>{hoge}</p>
        </div>
      ))}
    </>
  );
};

# Transitionしたい変数 → hogeList
HogeList.tsx

この2つのHooksを使い分ける基準は模索中ですが、基本的にはuseTransitionisPendingをうまく活用しながらインタラクティブに実装していく方針が良いのかなと思ったりします。

Suspense

前段としてバージョン16.6.0で追加されていたReact.lazyをおさらいします。

React含めSPAのプロジェクトではユーザーがページを訪れる前にあらかじめJavaScriptファイルを読み込んでおく必要があります。

そのため、規模が大きくなると初回の読み込みに時間がかかってしまう問題があります。

そこで指定した重たいコンポーネントを遅延読み込みすることで初回のバンドルサイズを小さくし、必要なタイミングでインポートする機能を提供してくれていました。

この時点ですでにSuspenseは提供されていました。

ただ、18で追加されたSuspenseの機能は上記とは少し違う用途になります。

ちなみに、本題から脱線しますが、有名なbulletproof-reactreact-router-domと絡めたlazyImportの実装はとても参考になります。

18で追加されたSuspenseはデータの受け取り状態を検知するコンポーネントであり、データを受け取るまではfall backに指定したコンポーネントを返し、データを受け取った後に子要素を返す動きをします。

使い方はシンプルなものであれば以下のような流れになります。

① Suspenseを有効にしたいコンポーネントをラップする

import React, { Suspense } from 'react';
import { BookList } from './BookList'
import './App.css';

function App() {
  return (
    <div className="App">
      <Suspense fallback={<p>loading..</p>}>
        <BookList />
      </Suspense>
    </div>
  );
}

export default App;
App.tsx

② ラップしたコンポーネントでPromiseをthrowする

import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

interface Book {
  id: number;
  title: string;
}

const fetchBooks = async () => {
  const { data } = await axios.get<Book[]>(
    'https://xxxxxx/books'
  );
  return data;
};

export const BookList = () => {
  const { data } = useQuery<Book[]>(['books'], fetchBooks, { suspense: true });
  # 第3引数を { suspense: true } にすることでSuspenseに対応

  return (
    <div>
      {data?.map((book) => (
        <p key={book.id}>{book.title}</p>
      ))}
    </div>
  );
};
BookList.tsx

react-queryのsuspenseモードを利用することで少ない記述で実装できます。

Suspenseに対応したライブラリはSWRなどいくつかり、それらを使っていくことが基本戦略になりそうです。

従来は独自にローディング状態をstateで管理して実装していたことが多かったと思いますが、その必要がなくなりとても宣言的に実装ができますね。

もちろん複数コンポーネントをSuspenseコンポーネント配下に置くことでまとめて設定することは可能ですが、気をつけないといけないのは、例えば1つだけ表示が遅いコンポーネントで、他のコンポーネントは比較的すぐにデータ取得できる場合、まとめてしまうと全体が表示が遅くなる(遅いコンポーネントにつられて表示されない)ことになります。

対策としては、Suspenseはネストして利用することや、適切にSuspenseを分割していく方法が良いのかなと思います。

import React, { Suspense } from 'react';
import { BookList } from './BookList'
import { TodoList } from './TodoList'
import './App.css';

function App() {
  return (
    <div className="App">
      <Suspense fallback={<p>loading..</p>}>
        <BookList />
      </Suspense>
      <Suspense fallback={<p>loading..</p>}>
        <TodoList />
      </Suspense>
    </div>
  );
}

export default App;
App.tsx

Suspenseを利用することで

  • API実行時のローディング状態の記述をより宣言的にできる
  • 適切に分割することでUX向上につながる

最後に

重要そうな4つの機能を紹介しました。

中でもSuspenseを理解することが今後のSSR対応の肝になると思われるのでSelective Hydrationなど本質の部分をこれからキャッチアップしつつ、今後の動向を見ていくことが大事だなーと。(←自分に言い聞かせてる)

参照