LoginSignup

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 3 years have passed since last update.

[修正前記事]useContext + useState におけるパフォーマンス上の懸念と、その解消法として(不本意ながら)Reduxを使っちゃう話

Posted at

本記事は「」の修正前の記事を記録しておくために作成しました。
URLを知っている人しかアクセスできない限定共有投稿になっていますが、
もしあやまって本記事にアクセスした方は、
「」の内容をご参照ください。


20200608 08:08 追記

本記事は内容に誤認があるため修正を検討しています。概要としては以下の通りです
1. 懸念1に関してはコンポジションの利用で回避できる。
2. 懸念2に関しては多層にProviderを用意すればContextの利用でも回避可能。(実際React-Reduxは内部的にContextを利用している)

詳細な内容は、以下のtwitterリンク、および返信をご参照ください。
(Takepepe様、コメントありがとうございます)
https://twitter.com/takepepe/status/1269756195650170880

内容は改めて調査して、記事を更新したいと考えています。
(それなりに力を入れたいと考えているので、早くても1週間はかかると思います)

現時点では本記事は、

・サンプルコードの形で実装をした際、gifで示しているような再レンダリングの動作が見られる
・Reduxを用いた際、比較的用意に改善できる。(Contextでの回避方法は今後更新予定)

という内容であると理解いただければ幸いです

最後に本記事は調査状況に応じてタイトルが変わる可能性があります。ご了承ください


20200609 13:18 再追記

本記事に関連する内容として、コメント欄で素晴らしい記事を教えていただきましたので共有させていただきます。是非ご参照ください。
React の Context の更新による不要な再レンダリングを防ぐ


本記事は、useContext + useState を用いた「グローバルなステート管理」におけるパフォーマンス上の懸念と、(不本意ながら)Reduxでその懸念を解消できることを、コードベースの実例を用いながら説明します。

Redux寄りの記事ですが、私はRedux好きじゃないです。 正直 useContext + useState でいけるならそれでいいじゃん! くらいに思っていました。ただ調べているうちに、無視できないパフォーマンス上の懸念点を知ってしまいました。そんなこと、私は知りたくなかった。。。

といった感じの悲しい気持ちを共有したいと思って書いたのがこの記事です。
まぁ知ってる人も多い話でしょう。でも知らない人がいたらぜひ知ってください。
みんなで悲しみを分かち合おう:smiling_imp:

雑な動機ですが、内容は真面目に書きました。
この記事が常にカオスな「Reactにおけるグローバルなステート管理」に対する理解の一助となれば幸いです。

なお本記事で利用するサンプルコードは全て以下のURLでgithubに展開しています。
(サンプル1, 2, 3のそれぞれに対応したブランチを作成しています)
必要に応じてご参照ください。
https://github.com/takahiromasui001/try-use-context

想定読者

  • Redux嫌い! なんで useContext + useState じゃダメなの!?:rage: という憤りを抱えている方
    (つまり私の同志)
  • ステート管理に useContext + useStateとReduxのどちらを使おうか迷っている方
    (本記事はあくまで判断材料の1つにはなると思います)

本記事で取り扱わない話

  • useContext + useState や Redux(& Redux Toolkit)の使い方
  • ReduxのMiddlewareやデバッグツール
    • Reduxの明確な利点ではありますが、本記事ではステート管理そのものにフォーカスします
  • Apollo(& GraphQL)の話
    • 力不足でここはカバーできていません。許してください m(_ _)m

結論

最初に本記事の結論を記載します。
(これだけで理解できてしまう方は、恐らく以降を読む必要は無いでしょう。)

  • useContext + useState によるグローバルなステート管理には、パフォーマンス上の懸念点が2つ存在する
    1. 「Contextの更新 = 親コンポーネントのステート更新」になってしまうので、
      全ての子コンポーネントが再レンダリングされる
    2. Contextが変更されると、それを利用する(useContextを使用する)全てのコンポーネントが再レンダリングされてしまう
  • "1."はReact.memo等で防ぐことができるが、"2."はContextを利用する以上避けられない。

  • "1."も"2."もReduxを使えば回避できる。

本題: useContext + useState におけるパフォーマンス上の懸念と、その解消法として(不本意ながら)Reduxを使っちゃう話

以降本題です。

懸念点1: 「Contextの更新 = 親コンポーネントのステート更新」なので、全ての子コンポーネントが再レンダリングされる

早速懸念点に関して説明していきます。

そのためにまず useContext + useState を用いた簡単なアプリを用意しました。こちらをご覧ください。

first.gif

App Component(薄灰色)の配下にComponent1(薄緑), Component2(薄青), Component3(薄黄)の3つの子コンポーネントを配置しています。それぞれのコンポーネントにはテキスト(ComponentX (useXXX))とボタン(checked)が配置されており、ボタンを押すとテキストが変わります。

このアプリではテキストの文言をステートとして扱っています。Component1 & 2は Contextを用いており、Component3だけは通常のローカルなステートを利用しています。各コンポーネントのボタンを押すことでこれらのステートが更新されます。

以下にサンプルコードを示しました。内容はチュートリアルレベルのとても単純なものです。(共通化しよう!とかは言わないでください)

// サンプル1: useContext + useStateの利用
import React, { createContext, useState, useContext, useCallback } from 'react'
import './App.scss'

type TTexts = {
  component1: string,
  component2: string
}

type TTextsContext = {
  texts: TTexts,
  setTexts: React.Dispatch<React.SetStateAction<TTexts>>
}

const initialValue = {
  component1: 'Component1(use Context)',
  component2: 'Component2(use Context)',
}

const TextsContext = createContext({} as TTextsContext)

const App = () => {
  const [texts, setTexts] = useState(initialValue)
  console.log('rendering App')

  return (
    <TextsContext.Provider value={{ texts, setTexts }}>

      <div className="App" >
        <div className="app-text">App Component</div>
        <Component1 />
        <Component2 />
        <Component3 />
      </div>
    </TextsContext.Provider>
  )
}

const Component1 = () => {
  const { texts, setTexts } = useContext(TextsContext)

  console.log('rendering Component1')

  return (
    <div className="component1">
      {texts.component1}
      <button onClick={() => setTexts({ ...texts, component1: 'checked(use Context)' })}>Checked</button>
    </div>
  )
}

const Component2 = () => {
  const { texts, setTexts } = useContext(TextsContext)

  console.log('rendering Component2')
  return (
    <div className="component2">
      {texts.component2}
      <button onClick={() => setTexts({ ...texts, component2: 'checked(use Context)' })}>Checked</button>
    </div>
  )
}

const Component3 = () => {
  const [text, setText] = useState({ component3: 'Component3(use LocalState)'})

  const onClick = useCallback(() => setText({ component3: 'checked(use LocalState)' }), [setText])

  console.log('rendering Component3')
  return (
    <div className="component3">
      {text.component3}
      <button onClick={onClick}>Checked</button>
    </div>
  )
}

export default App;

ボタンを押すとステートが更新され、テキストに反映するために再レンダリングが行われます。見た目上更新されるのは、(当然ですが)ボタンと同一コンポーネントのテキストです。再レンダリングは関連する1つのコンポーネントのみで完結するのが期待動作でしょう。

ローカルなステートを用いるComponent3ではこの動作が実現されています。以下のような感じです。
(ReduxDevToolの機能により、再レンダリングが起きた領域がハイライトされます)

component3 アップデート.gif

少し見えにくいかもしれませんが、ボタンをクリックした瞬間に、Component3の周りだけハイライトが表示されているのが分かります。これは望ましい動作です。

しかし useContext を用いたComponent1, 2では動作が異なります。

component1,2 アップデート.gif

Component3の場合と比較してハイライトの表示のされ方が明らかに異なります。Component1, 2のボタンを押した際は、どちらも全てのコンポーネントが再レンダリングされてしまっていることが分かります。Contextと全く関係ないComponent3ですら再レンダリングされます。

これは全く期待の動作ではありません。このアプリに限って言えば、非常にシンプルな作りですので影響は少ないかもしれません。しかし複数人で中・大規模なアプリを開発する際に同じことが起きたら、つまりボタンを1つ押しただけで全てのコンポーネントが再レンダリングされるようなことになったら、それは到底受け入れられるものではないでしょう。

原因

なぜこのような事が起きてしまったのでしょうか?その原因は端的に、
「useContextを用いたContextの更新は、結局Appコンポーネントのtextsステートを更新しているだけだから」と表現できます。そしてこれが本記事で扱いたい1つ目の懸念点です。

実はContextそのものは、子コンポーネントから親コンポーネントのステートを参照・更新する手段を提供しているにすぎません。ステート自体の保持・更新の機能は、通常のuseState(やuseReducer)によりまかなっています。

実際公式ドキュメントにも以下のような記載があります。

コンテクストはツリーの各階層で明示的にプロパティを渡すことなく、コンポーネント間でこれらの様な値を共有する方法を提供します。
https://ja.reactjs.org/docs/context.html

"共有する方法"を提供しているだけで、何を共有するかはContextのカバー範囲外なのです。

具体的にサンプル1のコードで言うのなら、

const Component1 = () => {
  // 中略
    <div className="component1">
      {texts.component1}
      <button onClick={() => setTexts({ ...texts, component1: 'checked(use Context)' })}>Checked</button>
    </div>
  )
}

の {texts.component1} や setTexts(..略..) (つまりContextの参照・更新)は、

const App = () => {
  const [texts, setTexts] = useState(initialValue)
  // 以下略

の texts や setTexts (つまりAppのステート)を直接扱っているのと同じです。

それを踏まえると Component1 or 2のボタンを押した際には、
以下の流れでコンポーネントの再レンダリング処理が行われることが分かります。

  1. Appコンポーネントのステートが更新される
  2. (ステートが更新されたので) Appコンポーネントが再レンダリングされる
  3. (Appコンポーネントが再レンダリングされたので) Appコンポーネントの子コンポーネント(この場合Component1, 2, 3)の全てが再レンダリングされる1

これがuseContextを利用したComponent1, 2において、「ボタンを1つ押しただけで全てのコンポーネントが再レンダリングされた」原因であり、懸念点の1つ目です。

なおローカルなステートを用いているComponent3では、ボタンを押しても「Appコンポーネントのステートが更新」されないので、この事象は起きません。

React.memoによる懸念点1の解消

この懸念点は、Appコンポーネントと Component1, 2, 3の間に、React.memoを用いたWrapperコンポーネントを挟むことで解消できます。少し分かりにくいので図で説明します。(図は分かりやすさの為にある程度正確さを犠牲にしている可能性があります)

スクリーンショット 2020-06-08 1.43.53.png

特筆するべきは、Wrapperコンポーネントによって、Appコンポーネントの再レンダリングの影響をカットしている点です。

React.memoを用いて宣言されたコンポーネントは、propsに変更が無い時にレンダーをスキップし最後のレンダー結果を再利用します2。 図から見て取れるように、Appコンポーネントが再レンダリングされても、Wrapperのpropsに変化はないため、Wrapperの再レンダリングはスキップされます。その結果として、(Appコンポーネントの再レンダリング契機では)Component1, 2, 3はレンダリングされない、ということになります。

実現するサンプルコードを以下に示します。
(Component1, 2, 3に関しては変更がないため割愛しました。)

// サンプル2: useContext + useState & React.memoの利用
const App = () => {
  const [texts, setTexts] = useState(initialValue)
  console.log('rendering App')

  return (
    <div className="App" >
      <div className="app-text">AppComponent</div>
      <TextsContext.Provider value={{ texts, setTexts }}>
        <WrapperComponent />      
      </TextsContext.Provider>
    </div>
  )
}

const WrapperComponent = React.memo(() => {
  console.log('rendering Wrapper')
  return (
      <div className="wrapper">
        <div className="wrapper-text">WrapperComponent using React.memo</div>
        <Component1 />
        <Component2 />
        <Component3 />
      </div>
  )
})

AppコンポーネントがWrapperコンポーネントを呼び出し、WrapperコンポーネントがComponent1, 2, 3を呼び出しています。WrapperコンポーネントはReact.memoを用いて宣言しています。

この状態で動作を見てみましょう。Wrapperコンポーネントの領域を灰色で示しました。Component1, 2, 3をラッピングしていることが視覚的にも分かるはずです。WrapperコンポーネントによってAppコンポーネントの再レンダリングの影響をReact.memoで防いでいるため、先ほどのように全てのコンポーネントが再レンダリングされることはないはずです。。。!

React.memo.gif

あれ?おかしいですね;思ったよりハイライト = 再レンダリングの状況が変わりません。

(少し分かりにくいですが)Component3はレンダリングされないようになりました。また、Appはステートが更新されるのでレンダリングされるのは仕方ありません。(Wrapperコンポーネントもハイライトされていますが、範囲が同じContextのProviderのコンポーネントが更新されているだけで、実際には再レンダリングされていません)

しかし、Component1 & 2は、どちらのボタンを押しても両方一緒に更新されてしまいます。Component1のボタンを押した時にはComponent1だけが、Component2のボタンを押した時にはComponent2だけが、それぞれ更新されてほしいのですが、、、そうなってはくれませんね:frowning2:
なぜでしょう。。。

懸念点2: Contextが変更されると、それを利用する(useContextを使用する)全てのコンポーネントが再レンダリングされてしまう

ここで登場するのが懸念点2です。これはContextの仕様に由来するものです。

端的に言えばこの章のタイトルの通り、
「Contextが変更されると、それを利用する(useContextを使用する)全てのコンポーネントが再レンダリングされてしまう」
ということです。

図で表すと以下のようになります。
(先ほどと同様に、図は分かりやすさの為にある程度正確さを犠牲にしている可能性があります)

スクリーンショット 2020-06-08 1.43.45.png

Contextを利用したコンポーネントは一蓮托生、連帯責任って感じですね。
公式ドキュメントではこのことを以下のように表現しています。

プロバイダの子孫の全てのコンシューマは、プロバイダの value プロパティが変更されるたびに再レンダーされます。プロバイダからその子孫コンシューマ(.contextType や useContext を含む)への伝播は shouldComponentUpdate メソッドの影響を受けないため、コンシューマは祖先のコンポーネントが更新をスキップしている場合でも更新されます。
https://ja.reactjs.org/docs/context.html

具体的にサンプルコードの例でいうなら、

const Component1 = () => {
  const { texts, setTexts } = useContext(TextsContext)
  // 以下略
}

const Component2 = () => {
  const { texts, setTexts } = useContext(TextsContext)
  // 以下略
}

const Component3 = () => {
  const [text, setText] = useState({ component3: 'Component3(use LocalState)'})
  // 以下略
}

Component1, 2は Contextを利用している(useContextを使っている)ので、どこかで同じContextが更新されたらまとめて再レンダリングされます。

逆にComponent3は 通常のuseStateのみを利用しているため、Contextの影響を受けません。
そのため先ほどの動作確認では、Component3のみ再レンダリングを免れていたのです。

この懸念点2の影響はアプリの規模が大きくなればなるほど無視できないものになります。また懸念点2はContextの仕様によるもので、Contextを利用する以上避けることはできません。

「規模が大きくなるとContextでは辛くなってくる」 といった話はこの懸念点2が元になっていると考えて良いでしょう。(正確には他にも要因はありますが、一番の根元はここかな?と個人的には思っています)

Reduxを利用した懸念点2の解消

上で紹介した懸念点は、(残念ながら)Reduxを用いることで比較的容易に回避できます。

Reduxにはとても面倒なイメージもあると思いますが、Redux Tookit & React Reduxのhooks(useSelector, useDispatch)などを利用することで、useContext + useStateに近い実装負荷に抑えることができる、と言えなくもないです。
(少なくとも素のReduxよりは、はるかにマシです。)

サンプルコードと動作をお見せします。

// サンプル3: Reduxの利用
import React, { useState, useCallback } from 'react'
import { Provider, useSelector, useDispatch } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import textsReducer, { TTexts, checkedComponent1, checkedComponent2 } from './textsSlice'
import './App.scss'

const store = configureStore({ reducer: textsReducer })

const App = () => {
  console.log('rendering App')

  return (
    <div className="App" >
      <Provider store={store}>
        <div>App Component</div>
        <WrapperComponent />      
      </Provider>
    </div>
  )
}

const WrapperComponent = React.memo(() => {
  return (
      <div className="wrapper">
        <div className="wrapper-text">Wrapper Component using React.memo</div>
        <Component1 />
        <Component2 />
        <Component3 />
      </div>
  )
})

const Component1 = () => {
  const component1 = useSelector((state: TTexts) => state.component1)
  const dispatch = useDispatch()

  console.log('rendering Component1')

  return (
    <div className="component1">
      {component1}
      <button onClick={() => dispatch(checkedComponent1())}>Checked</button>
    </div>
  )
}

const Component2 = () => {
  const component2 = useSelector((state: TTexts) => state.component2)
  const dispatch = useDispatch()

  console.log('rendering Component2')
  return (
    <div className="component2">
      {component2}
      <button onClick={() => dispatch(checkedComponent2())}>Checked</button>
    </div>
  )
}

const Component3 = () => {
  const [text, setText] = useState({ component3: 'Component3(use LocalState)'})

  const onClick = useCallback(() => setText({ component3: 'checked(use LocalState)' }), [setText])

  console.log('rendering Component3')
  return (
    <div className="component3">
      {text.component3}
      <button onClick={onClick}>Checked</button>
    </div>
  )
}

export default App;

// textsSlice(別ファイル)
import { createSlice } from '@reduxjs/toolkit'

export type TTexts = {
  component1: string,
  component2: string
}

const textsSlice = createSlice({
  name: 'texts',
  initialState: {
    component1: 'Component1(use Context)',
    component2: 'Component2(use Context)',
  },
  reducers: {
    checkedComponent1(state: TTexts) {
      state.component1 = 'checked(use Redux)'
    },
    checkedComponent2(state: TTexts) {
      state.component2 = 'checked(use Redux)'
    },
  }
})

export const { checkedComponent1, checkedComponent2 } = textsSlice.actions

export default textsSlice.reducer

redux.gif

見ての通り、Component1, 2, 3のいずれのボタンを押した際でも、必要最低限のコンポーネントがハイライト(= 再レンダリング)されていることが分かりますね。(チッ)
特に Redux Tookit & React Reduxのhooksを使うことで connect(HOC)を使う必要がないので、各コンポーネントでかなり気軽にReduxのstoreを利用することができる、と言ってやらんでもないです。

いやまぁそろそろ諦めます。パフォーマンス対応(不要なレンダリングの除去等)が必要になるステートの扱い方をするのであれば、useContext + useState よりもReduxを使う方が考えることが少なくなって楽そうです。自分でもサンプル作ってみて初めて理解しましたが、ここまで露骨に違いが出るとは。

悔しいが認めざるを得ない。。。ぐぬぬ。。。

まとめ

でもReduxは嫌いじゃ👹 Recoilはよ。

と言いながらRedux Tookit & React Reduxのhooksの使い心地は悪くなかったので、
多分今後もしばらくはReduxを擦っていく気がする。。。

それからuseContext + useState -> Reduxへの置き換え負荷は(Toolkit & hooks使うなら)それほどでもなかったので、最初はuseContext + useState とい運用もありかもしれない(もちろん規模にもよる)

最後にこの記事で述べたパフォーマンス上の懸念が、実際の開発でどの程度の規模から問題になり得るのかが分からない。。。有識者がいたらぜひコメントくださいm(_ _)m

参考サイト

Redux vs. Context vs. State
https://medium.com/better-programming/redux-vs-context-vs-state-4202be6d3e54

Facebook製の新しいステート管理ライブラリ「Recoil」を最速で理解する
https://blog.uhy.ooo/entry/2020-05-16/recoil-first-impression/

コンテクスト – React
https://ja.reactjs.org/docs/context.html


  1. コンポーネントの再レンダリング条件に関しては、次の記事参照: Reactコンポーネントの再レンダリング条件 

  2. React.memo(公式ドキュメント) 

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up