eact-reduxを読む - 更新処理編


はじめに

前回はconnect関数が返すConnectFunctionのうち、初めに表示される際の処理について見てきました。今回はReduxのStateが更新される際にどのような動作が行われ、表示が更新されるのかについて見ていきましょう。

Reduxの更新処理

Reduxについてはご存知という前提ですが簡単に確認しましょう。Reduxには以下の要素があります。

  • Store
    Stateを保持するオブジェクト。
  • Action
    Storeに対する変更要求。Storeのdispatchメソッドを使って送信する。
  • Reducer
    現在のStateとActionから次のStateを作成する関数。この記事ではあまり出てこない。

またStoreにはsubscribeメソッドがあり、Actionがdispatchされたら呼び出されるコールバックを登録することができます。このsubscribeが今回の鍵となります。

Provider

前回も少し見たProviderコンポーネントから始めましょう。

Provider.js抜粋
function Provider({ store, context, children }) {
  const contextValue = useMemo(() => {
    const subscription = new Subscription(store)
    subscription.onStateChange = subscription.notifyNestedSubs
    return {
      store,
      subscription
    }
  }, [store])

  const previousState = useMemo(() => store.getState(), [store])

  useEffect(() => {
    const { subscription } = contextValue
    subscription.trySubscribe()

    if (previousState !== store.getState()) {
      subscription.notifyNestedSubs()
    }
    return () => {
      subscription.tryUnsubscribe()
      subscription.onStateChange = null
    }
  }, [contextValue, previousState])

まずSubscriptionオブジェクトが作られています。この中身はこの後で見ていきます。
その次のuseEffectはまたよくわからない書き方です。このuseEffectはuseMemoと同じようにReactが提供する関数ですが、少し毛色が異なります。useEffectは副作用のある処理を行いたい場合に使うようです。ドキュメントにあるように「データの購読(英語ページだとsubscriptions)」は副作用があるためuseEffectを使う必要があるようです。

useEffectを含めたuseシリーズはReact 16.8で導入されたフックAPIです。フックは「クラスを使わずに関数で、クラスを定義して行っていたState管理(ReduxのではなくReact本体のstate)等を実装する機能」です。
その中でuseEffectはクラス定義のコンポーネントで言うcomponentDidMountとcomponentWillUnmount に相当するものだそうです。「useEffectに渡す関数」がcomponentDidMount、「useEffectに渡す関数がreturnする関数」がcomponentWillUnmount。
「購読の開始と解除を並べて書けるからいいでしょ」とドキュメントに書かれてますが、個人的にはインデントレベルが変わるのが微妙…(それとuseEffectのことを知らない人が見たときに理解するのに時間がかかる)という気がします。

Subscription

さて、Subscriptionに移ります。utils/Subscription.jsに定義されています。

Subscription.js抜粋
export default class Subscription {
  constructor(store, parentSub) {
    this.store = store
    this.parentSub = parentSub
    this.unsubscribe = null
    this.listeners = nullListeners

    this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
  }

  trySubscribe() {
    if (!this.unsubscribe) {
      this.unsubscribe = this.parentSub
        ? this.parentSub.addNestedSub(this.handleChangeWrapper)
        : this.store.subscribe(this.handleChangeWrapper)

      this.listeners = createListenerCollection()
    }
  }

  handleChangeWrapper() {
    if (this.onStateChange) {
      this.onStateChange()
    }
  }

  notifyNestedSubs() {
    this.listeners.notify()
  }

Providerコンポーネントで作られるSubscriptionオブジェクトはparentSubを渡していないのでStoreに対してsubscribeが行われます。Storeからコールバック(handleChangeWrapperメソッド)が呼ばれるとonStateChangeが呼ばれます。今の場合onStateChangeに設定されているのはnotifyNestedSubsです。自身のlistenerに対して変更があったことを伝えるという一般的なPub/Subモデルですね。

ここでnotifyされる対象は誰なのか、その後どう動くのか、ということについて調べるために、ConnectFunction関数の前回読み飛ばした部分に進みましょう。

ConnectFunction再び

ConnectFunctionではchildPropsSelectorを作った後に以下のコードがあります。githubでの表示はこちら

connectAdvanced.js抜粋
      const [subscription, notifyNestedSubs] = useMemo(() => {
        if (!shouldHandleStateChanges) return NO_SUBSCRIPTION_ARRAY

        // This Subscription's source should match where store came from: props vs. context. A component
        // connected to the store via props shouldn't use subscription from context, or vice versa.
        const subscription = new Subscription(
          store,
          didStoreComeFromProps ? null : contextValue.subscription
        )

        // `notifyNestedSubs` is duplicated to handle the case where the component is unmounted in
        // the middle of the notification loop, where `subscription` will then be null. This can
        // probably be avoided if Subscription's listeners logic is changed to not call listeners
        // that have been unsubscribed in the  middle of the notification loop.
        const notifyNestedSubs = subscription.notifyNestedSubs.bind(
          subscription
        )

        return [subscription, notifyNestedSubs]
      }, [store, didStoreComeFromProps, contextValue])

Subscriptionオブジェクトが作られていますが今度は第2引数、つまりparentSubが渡されています(正確にはコンテキストのStoreを使う場合は、ということになりますが通常はコンテキストを使うでしょう)

このsubscriptionがどこで使われているか見ていくと以下のコードがあります。少し読み飛ばしており「この変数何?」というものがいますがそこについては後から説明します。

connectAdvanced.js抜粋
      // Our re-subscribe logic only runs when the store/subscription setup changes
      useIsomorphicLayoutEffectWithArgs(
        subscribeUpdates,
        [
          shouldHandleStateChanges,
          store,
          subscription,
          childPropsSelector,
          lastWrapperProps,
          lastChildProps,
          renderIsScheduled,
          childPropsFromStoreUpdate,
          notifyNestedSubs,
          forceComponentUpdateDispatch
        ],
        [store, subscription, childPropsSelector]
      )

useIsomorphicLayoutEffectWithArgsはconnectAdvanced.jsの上の方に定義されています。ブラウザでの実行かサーバサイドレンダリング(SSR)かで呼ぶ関数を切り替えるということが行われていますがまあ結局useEffect、つまりrenderした後に実行される関数を登録しているという点ではあまり違いはありません。ということでsubscribeUpdatesに進みます。

subscribeUpdates

subscribeUpdatesはconnectAdvanced.jsの上の方に定義されていますがこれもまた難解です。

connectAdvanced.js抜粋
function subscribeUpdates(
  // 省略。上の配列に入ってるものが渡されてきます
) {
  // 省略

  // We'll run this callback every time a store subscription update propagates to this component
  const checkForUpdates = () => {
    // 後で見ます
  }

  // Actually subscribe to the nearest connected ancestor (or store)
  subscription.onStateChange = checkForUpdates
  subscription.trySubscribe()

  // 省略
}

上から読んでいくとややこしくなるのでまた先に構造を眺めてみました。今度はonStateChangeとしてsubscribeUpdates内に定義されているcheckForUpdatesが設定されています。
その後にtrySubscribeメソッド呼び出し。今度はparentSubがtruthy1なのでparentSubのaddNestedSubが実行されます。addNestedSubの先は淡々と頑張ってるだけなので省略します。

Subscription.js抜粋
  trySubscribe() {
    if (!this.unsubscribe) {
      this.unsubscribe = this.parentSub
        ? this.parentSub.addNestedSub(this.handleChangeWrapper)
        : this.store.subscribe(this.handleChangeWrapper)

      this.listeners = createListenerCollection()
    }
  }

以上のことからStoreにActionがdispatchされると次のように動作することがわかりました。

  1. Storeにdispatch
  2. Providerで作ったsubscriptionが呼び出される
  3. ConnectFunctionで作ったsubscriptionが呼び出される2

checkForUpdates

それではcheckForUpdate関数を見てみましょう。

connectAdvanced.js抜粋
  // We'll run this callback every time a store subscription update propagates to this component
  const checkForUpdates = () => {
    if (didUnsubscribe) {
      // Don't run stale listeners.
      // Redux doesn't guarantee unsubscriptions happen until next dispatch.
      return
    }

    const latestStoreState = store.getState()

    let newChildProps, error
    try {
      // Actually run the selector with the most recent store state and wrapper props
      // to determine what the child props should be
      newChildProps = childPropsSelector(
        latestStoreState,
        lastWrapperProps.current
      )
    } catch (e) {
      error = e
      lastThrownError = e
    }

    if (!error) {
      lastThrownError = null
    }

    // If the child props haven't changed, nothing to do here - cascade the subscription update
    if (newChildProps === lastChildProps.current) {
      if (!renderIsScheduled.current) {
        notifyNestedSubs()
      }
    } else {
      // Save references to the new child props.  Note that we track the "child props from store update"
      // as a ref instead of a useState/useReducer because we need a way to determine if that value has
      // been processed.  If this went into useState/useReducer, we couldn't clear out the value without
      // forcing another re-render, which we don't want.
      lastChildProps.current = newChildProps
      childPropsFromStoreUpdate.current = newChildProps
      renderIsScheduled.current = true

      // If the child props _did_ change (or we caught an error), this wrapper component needs to re-render
      forceComponentUpdateDispatch({
        type: 'STORE_UPDATED',
        payload: {
          error
        }
      })
    }
  }

ちょっと長いですが略すところがないので。2行にまとめると以下のようになります。

  1. StoreからStateを取得してSelectorを実行
  2. propsに変化があるようならforceComponentUpdateDispatchを実行

次の話題はforceComponentUpdateDispatch(とchildPropsFromStoreUpdateとか)とは何者なのかです。ここで飛ばした部分が出てきます。

三度ConnectFunction - useRefとuseReducer

先にchildPropsFromStoreUpdateから。ConnectFunctionに戻るとこれらは以下のように定義されています。

connectAdvanced.js抜粋
      // Set up refs to coordinate values between the subscription effect and the render logic
      const lastChildProps = useRef()
      const lastWrapperProps = useRef(wrapperProps)
      const childPropsFromStoreUpdate = useRef()
      const renderIsScheduled = useRef(false)

useと言ったらReactフック、ということでuseRefもご多分に漏れずReactが提供する関数です。意味合いとしてはクラスにおけるインスタンス変数みたいな機能を提供するもののようです。

forceComponentUpdateDispatchはもう少し上で定義されています

connectAdvanced.js抜粋
      // We need to force this wrapper component to re-render whenever a Redux store update
      // causes a change to the calculated child component props (or we caught an error in mapState)
      const [
        [previousStateUpdateResult],
        forceComponentUpdateDispatch
      ] = useReducer(storeStateUpdatesReducer, EMPTY_ARRAY, initStateUpdates)

useReducerは少し複雑です。ドキュメントにあるように動作としてはReduxのReducerと同じような感じです。
大事なのは、戻り値の二つ目で返されているdispatch(上のコードではforceComponentUpdateDispatchに代入されている)です。ドキュメントには明言されていませんが、dispatchを呼び出すことによりレンダリングのやり直しが行われるようになっています。

diapatchの先については、いわゆる「本書の範囲を超える」内容、React内部の話となるのでreact-redux読解はこれにて終了となります。

更新時処理のまとめとあとがき

以上、更新時の処理を見てきました。Storeのsubscribeを使い、Reactのフックを駆使し、まさに「間をつなげる」にふさわしい処理が行われていました。プログラミング技術と言うか、「ReactのフックAPIはこう使え!」の見本みたいな感じでしたね。

ちなみに、Reactにフックが導入されたのは本文中にも書いたように16.8、2019/2/6です。当時Twitterで「クラス定義コンポーネントよさようなら」みたいなことを言ってる人を見た気がしてなんのこっちゃと思ってたのですが3こういうことだったんですね。まあでもフックは難しいので初心者はクラスから入るべきだと思います。

react-reduxもReactにフックが入ったことで書き直されたものがv7だということは更新時処理を本格的に眺め始めてから気づきました(ところでReact 16.8より前はどう実装されてたの?と)。いろいろな縁で(?)非常にJavaScriptらしい関数使いまくりなコードに巡り合えた気がします。


  1. 条件として使うとtrueと判断されるもの 

  2. subscriptionの親子関係は一段だけではなく、connectしたコンポーネントを親にまた親子関係ができることもありますが、読むのがややこしくなるのでここら辺の説明は省略します。 

  3. この当時はまだReactちょっと触ったことある程度で動向についてはほとんど知りませんでした。