React18についての紹介

media thumbnail

初めまして、株式会社Gizumoでエンジニアをしているasaiです。

今回は2022年3月29日にリリースされたReactのバージョン18で追加された機能について、簡単にご紹介いたします。

前提・対象とする読者

この記事の読者は以下のような方を想定しています。

  • Reactのバージョン17までの機能について理解がある方
  • Reactのバージョンを17以前から18へアップデートを検討している方

Reactのレンダリングに関するアルゴリズムについて

React18の機能について見ていく前に、まずはこれまでのReactのレンダリングの仕組みについて復習しておきましょう。

HTMLのツリー構造に関して、DOMの形で変更前と変更後の形を保持しておいて、差分だけ更新してあげるという処理を差分検出処理(リコンシリエーション)と呼び、これを実現するプログラムをリコンサイラと呼びます。

Reactのバージョン15まではStackと呼ばれるリコンサイラが採用されていたため、DOMは基本的に「上から順番に」処理されていました。
しかしながら、このような処理方法は同期的な処理と似た問題を孕んでいるため、例えばあるDOMの更新処理が途中で停止した場合、次のDOMの更新処理も止まってしまい更新処理の間アプリが動かなくなる、といった問題が発生していました。

このような問題への対策として、複数のDOMを1単位としてまとめたものを一斉に読み込み、あるDOMの集合の更新処理で時間がかかったなら先に別のDOMの集合を処理させるというような非同期処理を差分検出処理(リコンシリエーション)に取り入れたものがReactのバージョン16以降で採用されたFiberと呼ばれるリコンサイラになります。

しかしながら、Fiberに関してはこれを操作するためのAPIがバージョン17まで用意されておらず、そのためユーザー側から特にリコンサイラとしてStackを使用したり、Fiberを使用するといった指定をすることが出来ませんでした。

このような流れを経て、ついにReactのバージョン18でFiberを操作するためのAPIが提供される運びとなりました。

非同期処理の3類型について

FiberはDOMの集合の更新を非同期処理するための仕組みのため、Fiberを操作するためのAPIについて知る前に、よく利用される非同期処理の3つのパターンについても簡単に復習しておきましょう。

非同期処理パターン1.Fetch-On-Render

コンポーネントがいったんレンダリングを完了するまでデータ取得が始まらない方法です。例えば、useEffectを単純に使った場合、このパターンになります。

このパターンの欠点としては親コンポーネントのデータ取得が完了し、レンダリングが終わるまで子コンポーネントのデータ取得が始められません。
さらには、親子関係が3階層、4階層と深くなっていくと数珠繋ぎ的に所要時間が増えていき、これをウォーターフォール問題と呼んだりします。

非同期処理パターン2.Fetch-Then-Render

子孫コンポーネントに必要となるデータを親コンポーネントでまとめて並列的に取得する方法です。
例えばPromise.allは、複数のPromiseを同時に並行で実行し、全てのPromiseが解決したらそれぞれの値を返してくれる方法です。

このパターンの欠点としては全てのデータ取得が終わらないとコンテンツのレンダリングができないので、
一つでもレスポンスに時間のかかるものがあると他のコンテンツのレンダリングが全てブロックされてしまいます。

さらにこの手法は子孫コンポーネントの表示に必要なデータが、親コンポーネントを実行する前に最初から全てわかってる場合にしか使えません。
さらにデータの扱いが親コンポーネントに一極集中するので設計の難易度が高く、メンテナンス性も悪いという問題があります。

非同期処理パターン3.Render-as-You-Fetch

まずデータ取得が一斉に開始され、それが完了しないまま見切り発車でレンダリングが開始します。
もしレンダリング中に未取得のデータが発生してしまった場合には、そのレンダリングを停止(サスペンド)します。

並行レンダリング(Concurrent Rendering)について

Fiberを操作することが出来る公開APIのことで、Render-as-You-Fetchを実現する技術です。

ちなみにconcurrentは「同時発生的な、並列的な」と言う意味で、そこから「並行」と言う意味で使われるようになりました。

Reactはデフォルトではレガシーモードとなっています。
このモードではレンダリングが一度始まると、レンダリング用のアルゴリズムがstackのせいで、最後まで止めることができません。
これをブロッキング・レンダリング(Blocking Rendering)と呼び、
レンダリングを待たせる(ブロックする)要因となる外部ファイルをレンダリングブロックリソースなどと呼んだりします。

例えば検索欄に言葉を1語ずつ入力するケースについて考えてみましょう。コンポーネントの設計によっては一打ごとにDOMの更新やAPI通信が発生し、まるで入力するたびに何かに引っかかっているようなユーザ体験を与えてしまうことがあります。

引っかかりが発生する理由はシンプルで、レンダーが始まってしまったら中断できないからです。
ReactのようなUIライブラリの性能がどれだけ良く見えようとも、それがブロッキングレンダリングを使用している限り、一定の割合で引っかかりと言う現象が発生してしまうというのがこれまでの問題でした。

一方、React18で採用された並行レンダリング(Concurrent Rendering)では、レンダリング用のアルゴリズムがFiberになっているおかげでレンダリングの中断処理が可能となっており、これにより各コンポーネントのレンダリングを適宜停止&再開しつつ適切にスケジューリングしてくれるようになります。
これをインターラプティブル・レンダリング(Interruptible Rendering/中断可能レンダリング)と呼んだりします。

ただし、このような中断処理は後述のstartTransitionまたはSuspenseをコンポーネントに適用した際に限定されるので、自動では行われないことに注意して下さい。

レンダリング方式の切り替え方法について

並行レンダリング(Concurrent Rendering)は公式でも極論を言えば破壊的変更に該当するような変更であると言及されているため、既存のアプリケーションに組み込む際は段階的な導入が可能となっています。react-domのrender()からreact-dom/clientのcreateRoot()を利用することで、簡単にレンダリング方式を切り替えることが出来ます。

レガシーモードの起動

import { render } from 'react-dom';
const container = document.getElementById('app');
render(<App tab="home" />, container);

Concurrent Renderingの起動

import { createRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App tab="home" />);

※なお、React18でレガシーモードを利用した場合、下記のような警告がコンソールに表示されるため注意して下さい。

ReactDOM.render is no longer supported in React 18. Use createRoot instead. 
Until you switch to the new API, your app will behave as if it’s running React 17.
Learn more: https://reactjs.org/link/switch-to-createroot

Fiberに関わるAPIについて

まず前提として知っておくべきものが、Suspenseになります。
この仕組みを利用することで遅延読み込みを行うことができるようになり、ローディング中の表示を自由自在に設計できるようになります。

Suspenseについては解説するとかなりのボリュームになってしまうので、割愛させて頂きます。

さて、Reactのバージョン18よりこのSuspenseに関わる新たな仕組みとして提供されたAPIがあります。
これが並行レンダリングの仕組みを利用し、更新処理に緊急度を付与できるhookであるuseTransitionになります。

import { useTransition } from 'react';

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  // ...
}

transitionとは、公式では段階的推移と訳されているもので緊急性の高い更新 (urgent update)高くない更新 (non-urgent update)を区別するための概念です。

ここで、緊急性の高い更新とは例えば前述した検索欄への文字入力イベントのような、タイプ、クリック、プレスといったユーザ操作を直接反映するイベントを指します。

デフォルトでは全ての関数が緊急性:高の状態であり、useTransitionを使用し、startTransitionでラップした更新は緊急性の低いものとして扱われ、クリックやキー押下のような緊急性の高い更新がやってきた場合には中断されるようになります。

これにより、ユーザの入力をブロックせずに画面遷移を行うことができ、より良いユーザー体験を提供することが出来るようになります。

その他にuseDeferredValueといった値更新に関する新規APIも追加されていますが、詳細は割愛いたします。下記の記事が参考になるかと存じますので、ご興味がある方はご一読頂くと理解が深まるかと思います。
https://qiita.com/uhyo/items/6be96c278c71b0ddb39b

自動バッチングについて

一般的にプログラムの文脈におけるBatchとは、複数の処理をひとまとめにした一連の処理内容を指しますが、Reactの文脈においては複数のstateの更新処理をひとまとめにすることを指します。

Reactのバージョン17まではReactのイベントハンドラなど一部の機能でしかBatchingが利用できませんでした。

const [hogeState1, setHogeState1] = useState(0);
const [hogeState2, setHogeState2] = useState(0);

/*
  クリックイベントはバッチ処理されるので、イベント内のset関数も更新時にまとめて処理される
  そのためonClickが呼ばれた時点ではstateの値は0のままで、レンダリングの時まで更新遅延される
*/ 
const onClick = () => {
  setHogeState1((hogeState1) => hogeState1 + 1) 
  // console.log(hogeState1)
  // hogeState1 = 0
  setHogeState2((hogeState2) => hogeState2 + 1)
  // console.log(hogeState2)
  // hogeState2 = 0
}

Reactのバージョン18からはこの範囲が拡大し、非同期処理内で複数のset関数が呼び出されstate更新が発生した場合でも、set関数を呼ぶたびに再レンダリングしていたものを1回にしてくれるため、より良いパフォーマンスを出すことが出来るようになりました。

const onClick = () => {
    fetch('https://hogeURI')
      .then((res) => res.json())
      .then((data) => {
        setHogeState1((hogeState1) => hogeState1 + data)
        setHogeState2((hogeState2) => hogeState2 + data)
      })
}

react-dom/clientのcreateRoot()を利用することで、こちらの機能も自動で適用される形となります。

もし、バッチ処理させたくないコンポーネントがある場合には、flushSync()を使うとバッチ処理を回避させることができるようになります。

その他のアップデート内容について

  • React.FC の props に暗黙的に children が含まれなくなったので、該当箇所で children を明示することが必要になりました。(自身もReactのバージョンアップについて関わったことがあるのですが、地味にここの置き換え作業が大変でした)
type Props = {
  children: ReactNode;
};

export const Component: FC<Props> = ({ children }) => {
  return <p>{children}</p>;
};
  • 新たなhookとしてuseIdが追加されました。一意のIDを生成するためのhookですが、勘違いしやすい内容として、keyを作成するのに使うためのものではないことに注意した方がいいです。
function Hoge() {
  const id = useId();

  return (
    <>
      <div id={id}>Hoge</div>
    </>
  );
};
  • strictMode使用時、useEffectが意図的に2回呼び出されるようになりました。これをstrict effectsと呼びます。本機能は開発モードの時のみ適用され、本番モードでは1回のみの呼び出しになります。これは開発時に意図しない副作用を事前検出することで、useEffectを正しく設計してほしいというReact開発側からのメッセージのようです。なぜこのような修正が加えられたかについては、Discussionsで詳しく述べられているのですが、将来のマイナーリリースもしくはメジャーリリースで登場するであろうOffscreen APIやそれ以外のmount、unmountを操作するような新規API機能の提供に向けた布石のようです。
React.useEffect(() => {
  console.log("マウント");
}, [])

/* 
  実行時、useEffectは意図的に2回呼ばれるためコンソール画面は以下のように表示される
  マウント 
  マウント
*/

総括

ここまで、React18の新機能について簡単に見ていきました。

公式にもReact18アップグレードガイドが提供されており、バージョンアップ自体は簡単に出来そうに見えますが、自身が関わっているプロジェクトではアップグレードに伴いDOMのレンダリング方式が変わったことにより、元々の処理が良くなかった箇所で盛大に不具合が生じてしまいました。。。

意外と一筋縄でいかないこともあるので、バージョンアップの際はしっかり時間をとり検証しながら作業を進めることをお勧めします。

少しでも開発にお困りの方は
相談しやすいスペシャリストにお問い合わせください

お問い合わせ
  1. breadcrumb-logo
  2. メディア
  3. React18についての紹介