2018/10/23にReact16.6がリリースされています
https://reactjs.org/blog/2018/10/23/react-v-16-6.html
上記ドキュメントで大きく取り上げられているのは
- React.memo
- React.lazy
- static contextType
- getDerivedStateFromError()
などですが、目立たないところで
- Rename unstable_Placeholder to Suspense, and delayMs to maxDuration. (@gaearon in #13799 and @sebmarkbage in #13922)
とあり、Suspenseが正式に追加されていました。 これはかつて React.Timeoutだったり、React.Placeholderだったりしたものです。
上記ドキュメントではReact.lazyのサンプルにSuspenseの例が書かれているのですが、説明が乏しかったので記事を書くことにします。
ErrorBoundaryについて
まず前提知識として、React 16にはErrorBoundaryという機能があります。
https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html
上記の記事のコピペですが例をあげると
1class ErrorBoundary extends React.Component {2 constructor(props) {3 super(props);4 this.state = { hasError: false };5 }67 componentDidCatch(error, info) {8 // Display fallback UI9 this.setState({ hasError: true });10 // You can also log the error to an error reporting service11 logErrorToMyService(error, info);12 }1314 render() {15 if (this.state.hasError) {16 // You can render any custom fallback UI17 return <h1>Something went wrong.</h1>;18 }19 return this.props.children;20 }21}22
というように、componentDidCatch
を定義することで、this.props.childrenの評価時に発生したエラーをキャッチすることができます。
使い方はErrorBoundaryの内部に別のコンポーネントを埋め込むだけです。
1class App extends React.Component {2 render () {3 return (4 <ErrorBoundary>5 <MyWidget />6 </ErrorBoundary>7 )8 }9}10
この子コンポーネントでthrow
されたものは、エラーでなくとも親コンポーネントのcomponentDidCatch
で取得することができます。
(同じく16.6で追加されたgetDerivedStateFromError
でも取得する事ができるようになりました。)
Suspenseとは
Suspenseは、子コンポーネントでエラーではなくてPromiseをthrowすることで、子コンポーネントのレンダリングを中断し、Promiseの完了時にレンダリングを再実行する機能です。これは先程説明した、ErrorBoundaryと全く同じ機構で実現されています。
これがあると、何が嬉しいのでしょうか。
これまで非同期でHTTPリクエストを投げて、結果を取得するまでLoading...
のようなテキストを表示し、その後に取得結果をレンダリングするためにはstateを使ってHTTPリクエストの状態を管理する必要がありました。
たとえばこんな感じ
1class NewsDetailPage extends React.Component {2 state = {3 loading: false,4 data: null5 }67 fetchData = async () => {8 try {9 this.setState({ loading: true })10 const res = await fetchNewsData({ id: this.props.id })11 this.setState({ loading: false, data: res.data.news })12 } catch (e) {13 console.error(e)14 }15 }1617 componentDidMount() {18 this.fetchData()19 }2021 render() {22 if (this.state.loading) {23 return <Loading />24 } else {25 return <NewsDetailLayout news={this.state.data} />26 }27 }28}29
これを以下のように書き換えることができます。
12// フェッチしたデータを保存しておく変数。あくまで説明用なので実際はこういう実装してはダメ。3// 本家では、APIリクエストを叩くメソッドをラップしてキャッシュ層を作る実装を推奨している。4// そうすると、キャッシュがないときだけAPIを叩いて、あるときは即時レンダリングなどできる。5// react-cache https://github.com/facebook/react/tree/master/packages/react-cache67let newsData;89// このコンポーネントは2回renderingが走る。10// 1回目: Promiseがthrowされ、Suspenseのfallbackがrenderされる11// 2回目: Promiseが解決されると自動的に2回目のrenderが始まる。newsDataにデータがセットされているので、NewsDetailLayoutがreturnされる。1213const NewsLayoutSuspense = (props) => {14 render() {15 if (!newsData) {16 throw new Promise((resolve, reject) => {17 fetchNewsData({ id: props.id })18 .then(res => {19 newsData = res.data.news20 resolve()21 })22 .catch(e => reject(e))23 })24 }2526 // 初回renderには以下はreturnされない。27 return <NewsDetailLayout news={newsData} />28 }29}3031const NewsDetailPage = (props) => {32 return (33 <Suspense maxDuration={500} fallback={<Loading />}>34 <NewsLayoutSuspense id={props.id} />35 </Suspense>36 )37}38
上記のように、うまく取得したAPIレスポンスをコンポーネントの外側に格納することができると、Functional Componentで従来stateなしで書けなかった処理を書くことができます。Suspenseはあくまでパターンなので、状況によって必要なSuspenseコンポーネントなどを書くとより便利そうです。
上記の例だと、Promiseを解決したあとにローカル変数に代入していましたが実際にやるときは、Promiseの結果を保存するキャッシュ機能を作る必要がありそうです。現時点で有力なのは、
react-cache https://github.com/facebook/react/tree/master/packages/react-cache
ですがAPIはすべてunstableになっているため、代替ライブラリを探すか、react-cacheのAPIに直接依存しないよう工夫が要ります。Suspenseはかなり強力なパターンなので、キャッシュ設計のベストプラクティスが固まれば普及しそうです。
参考
React Suspense を試してみた Reactの次期機能のSuspenseが凄くって、非同期処理がどんどん簡単になってた! https://github.com/facebook/react/releases
※Suspenseについては、ここ数ヶ月でAPIの命名がかなり変わっているので注意。
名称が変わった例:
- React.Timeout → React.Suspense
- simple-cache-provider → react-cache
- AsyncMode → ConcurrentMode