ぼくのかんがえたさいきょうの useState + useContext よりも Redux のほうが大抵勝っている

43 min read読了の目安(約38800字 2

「Redux は学習コストが高い」などと言って useState(または useReducer)と useContext を組み合わせ 劣化 オレオレ Redux を作ってしまうのを見かけます[1]。よくないことだと思いますが、気持ちは非常にわかります。Redux エコシステムがそういう気持ちにさせてしまう部分は大いにあります。

Redux は それ単体なら 学習コストは useReducer + useContext と同等であることを示してこの気持ち(誤解)を解かしつつ、なぜそういう気持ちになってしまうのか考察してみます。

まず useState と useReducer の違いを押さえておく

知っている方はスキップしてください。

useState と useReducer は本質的には同等で、どちらもコンポーネントにステート(状態)を持たせる役割があります。次のようなカウンターアプリを考えてみます。

useState の例:

App.tsx
export function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>今のカウント: {count}</p>

      <button
        onClick={() => {
          setCount((v) => v + 1)
        }}
      >
        カウントアップ
      </button>
    </div>
  )
}

useReducer の例:

App.tsx
export function App() {
  const [{ count }, dispatch] = useReducer(
    (state: { count: number }, action: { type: "increment" }) => {
      switch (action.type) {
        case "increment": {
          return {
            ...state,
            count: state.count + 1,
          }
        }
      }

      return state
    },
    { count: 0 }
  )

  return (
    <div>
      <p>今のカウント: {count}</p>

      <button
        onClick={() => {
          dispatch({ type: "increment" })
        }}
      >
        カウントアップ
      </button>
    </div>
  )
}

よくあるカウンターの例ですが、useReducer はただ冗長なだけです。題材チョイスがよくありません。

useReducer が生きるのは、複数の関連したステート、つまりオブジェクトをステートとして扱うときです。API の通信結果を保持する処理を書いてみると違いがわかります。

useReducer が生きる例:

App.tsx
export function App() {
  const [{ loading, users }, dispatch] = useReducer(
    (
      state: { loading: boolean; users: string[] },
      action:
        | { type: "pending" }
        | { type: "fulfilled"; payload: { users: string[] } }
    ) => {
      switch (action.type) {
        case "pending": {
          return {
            ...state,
            loading: true,
          }
        }

        case "fulfilled": {
          const { users } = action.payload

          return {
            ...state,
            loading: false,
            users,
          }
        }
      }

      return state
    },
    {
      loading: false,
      users: [],
    }
  )

  return (
    <div>
      {loading ? (
        <p>ロード中</p>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user}>{user}</li>
          ))}
        </ul>
      )}

      <button
        onClick={async () => {
          dispatch({ type: "pending" })

          const { users } = await fakeAPI()

          dispatch({
            type: "fulfilled",
            payload: {
              users,
            },
          })
        }}
      >
        データ取得
      </button>
    </div>
  )
}

useReducer に渡す関数(reducer という)に処理が寄って、ボタンの onClick 内が単純、つまりほぼ dispatch でアクション名を通知するだけになるのがキーです。

useState で同じ処理を書こうとすると、次のように、onClick 内の処理を注意深く書かねばなりません。オブジェクトとして一体化した loadingusers を扱う代わりに、それぞれ useState で管理することもできますが、onClick にロジックが寄ってしまう点は変えられません。

useState を使った例:

App.tsx
export function App() {
  const [{ loading, users }, setState] = useState<{
    loading: boolean
    users: string[]
  }>({ loading: false, users: [] })

  return (
    <div>
      {loading ? (
        <p>ロード中</p>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user}>{user}</li>
          ))}
        </ul>
      )}

      <button
        onClick={async () => {
          setState((state) => ({
            ...state,
            loading: true,
          }))

          const { users } = await fakeAPI()

          setState((state) => ({
            ...state,
            loading: false,
            users,
          }))
        }}
      >
        データ取得
      </button>
    </div>
  )
}

これくらいの例だと useState でも困らないのですが

  • onClick 内の処理を間違える(API 呼び出しの完了時に loading を変え忘れるなど)可能性がある
  • そのテストのためにはコンポーネントのテスト(高コスト)が必要

という脆さはあります。useReducer を使った例だと

  • onClick が単純(API 呼び出し前後に dispatch するだけ)でロジックと呼べるほどの処理を書くことがない
  • テストしたくなったら reducer を切り出して reducer のテストをすればよい。reducer は状態を持たない関数なのでテストが簡単

といった利点があります。

「useReducer のほうが onClick 内は単純」という点が腑に落ちない場合は、API が例外を投げたときを考えてみてください。useReducer の例では、reducer はいろいろ処理が必要そうですが、onClick 内は例外を捕まえて dispatch({ type: "rejected" }) すればいいだけです。

useReducer をコンポーネントをまたいだ状態管理に使う

オブジェクトとして表すような複合的なステートは useReducer で管理すると良さそうだ、というのを確認しました。

複合的なステートの最たるものは、アプリケーショントップレベルのステート、つまりグローバルステートですので、これを useReducer で扱ってみます。次のように、各記事のいいね数とグローバルヘッダーに総いいね数を表示するブログを例にとります。

(ソースコードをすべて書き下しますが、都度参照するので読み飛ばして構いません)

App.tsx
App.tsx
import React, { useReducer } from "react"
import { Article } from "./Article"
import { GlobalHeader } from "./GlobalHeader"
import { AppState, appStateContext } from "./useAppState"
import { AppAction, dispatchContext } from "./useDispatch"

export function App() {
  const [appState, dispatch] = useReducer(
    (state: AppState, action: AppAction) => {
      switch (action.type) {
        case "like": {
          const { articleId } = action.payload

          return {
            ...state,
            articles: state.articles.map((article) => {
              if (article.id !== articleId) {
                return article
              }

              return {
                ...article,
                likes: article.likes + 1,
              }
            }),
          }
        }
      }

      return state
    },
    {
      articles: [
        {
          id: "1",
          contents: "本来は API などから取得する記事の中身",
          likes: 0,
        },
        {
          id: "2",
          contents: "二つ目の記事",
          likes: 0,
        },
      ],
    }
  )

  return (
    <dispatchContext.Provider value={dispatch}>
      <appStateContext.Provider value={appState}>
        <GlobalHeader />

        <Article id="1" />

        <Article id="2" />
      </appStateContext.Provider>
    </dispatchContext.Provider>
  )
}
Article.tsx
Article.tsx
import React from "react"
import { useAppState } from "./useAppState"
import { useDispatch } from "./useDispatch"

export function Article({ id }: { id: string }) {
  const { articles } = useAppState()
  const dispatch = useDispatch()

  const article = articles.find((a) => a.id === id)
  if (!article) {
    return <article>404</article>
  }

  const { contents, likes } = article

  return (
    <article style={{ border: "solid 1px gray", margin: 8, padding: 8 }}>
      <p>{contents}</p>

      <div>この記事のいいね: {likes}</div>

      <button
        onClick={() => {
          dispatch({
            type: "like",
            payload: {
              articleId: id,
            },
          })
        }}
      >
        いいねする
      </button>
    </article>
  )
}
GlobalHeader.tsx
GlobalHeader.tsx
import React from "react"
import { useAppState } from "./useAppState"

export function GlobalHeader() {
  const { articles } = useAppState()

  return (
    <header style={{ padding: 16 }}>
      総いいね: {articles.reduce((acc, { likes }) => acc + likes, 0)}
    </header>
  )
}
useAppState.ts
useAppState.ts
import { createContext, useContext } from "react"

export interface AppState {
  articles: {
    id: string
    contents: string
    likes: number
  }[]
}

export const appStateContext = createContext<AppState | null>(null)

export function useAppState() {
  const appState = useContext(appStateContext)
  if (!appState) {
    throw new Error("Provider で囲んでください")
  }

  return appState
}
useDispatch.ts
useDispatch.ts
import React, { createContext, useContext } from "react"

export type AppAction = {
  type: "like"
  payload: {
    articleId: string
  }
}

export const dispatchContext = createContext<React.Dispatch<AppAction> | null>(
  null
)

export function useDispatch() {
  const dispatch = useContext(dispatchContext)
  if (!dispatch) {
    throw new Error("Provider で囲んでください")
  }

  return dispatch
}

App, GlobalHeader, Article の親子関係は次の図のとおりです。実際には ... の部分のコンポーネントはありませんが、要はツリーの末端同士で App の持つステートを共有しているということです。

各記事のいいね数は GlobalHeader と Article から参照できる必要があるので、共通の親である App に持たせます。appStateContextdispatchContext を使い、ステートと dispatch 関数を App 配下のどこでも参照できるようにしています。

App.tsx
export function App() {
  const [appState, dispatch] = useReducer(
    (state: AppState, action: AppAction) => {
      switch (action.type) {
        case "like": {
          // いいね数を増やす処理
        }
      }
    },
    {
      // appState の初期値
    }
  )

  return (
    <dispatchContext.Provider value={dispatch}>
      <appStateContext.Provider value={appState}>
        <GlobalHeader />

        <Article id="1" />

        <Article id="2" />
      </appStateContext.Provider>
    </dispatchContext.Provider>
  )
}

useAppState と useDispatch は、null チェックをするだけで、ほぼ useContext そのままです。GlobalHeader では appState の参照だけで、Article では appState の参照のほか dispatch によっていいね数を増やせるようになっています。

GlobalHeader.tsx
export function GlobalHeader() {
  const { articles } = useAppState()

  return (
    <header>
      総いいね: {articles.reduce((acc, { likes }) => acc + likes, 0)}
    </header>
  )
}
Article.tsx
export function Article({ id }: { id: string }) {
  const { articles } = useAppState()
  const dispatch = useDispatch()

  const article = articles.find((a) => a.id === id)
  if (!article) {
    return <article>404</article>
  }

  const { contents, likes } = article

  return (
    <article>
      <p>{contents}</p>

      <div>この記事のいいね: {likes}</div>

      <button
        onClick={() => {
          dispatch({
            type: "like",
            payload: {
              articleId: id,
            },
          })
        }}
      >
        いいねする
      </button>
    </article>
  )
}

useState(または useReducer)と useContext を組み合わせたオレオレ Redux の骨子は大体こんなところでしょう。実際動くし、React 本体しか使っていないし、破滅的ではありません。

しかしこの仕組みには問題が 2 つあります:

  • A) appState の全体がコンテキスト経由で渡っているので、appState が一部でも変わると useAppState を使ったコンポーネントがすべて再レンダリングされる。
  • B) そもそも appState が App のステートなので、appState が一部でも変わると App 配下のコンポーネントはすべて(useAppState を使っていようといまいと)再レンダリングされる。

次でこれを解決しましょう。

再レンダリングをできるだけ抑える(問題 B)

再レンダリングの原因は 2 つでした(再掲):

  • A) appState の全体がコンテキスト経由で渡っているので、appState が一部でも変わると useAppState を使ったコンポーネントがすべて再レンダリングされる。
  • B) そもそも appState が App のステートなので、appState が一部でも変わると App 配下のコンポーネントはすべて(useAppState を使っていようといまいと)再レンダリングされる。

まず後半の原因 B を取り除くべく、appState を App コンポーネントのステートにするのをやめます。AppStateStore クラスを作り、そのインスタンスに appState を保持させるのです。

AppStateStore.ts
import { AppState } from "./useAppState"
import { AppAction } from "./useDispatch"

export class AppStateStore {
  constructor(
    private reducer: (state: AppState, action: AppAction) => AppState,
    private appState: AppState
  ) {}

  getState = (): AppState => {
    return this.appState
  }

  dispatch = (action: AppAction) => {
    this.appState = this.reducer(this.appState, action)
  }
}
App.tsx
 import React, { useReducer } from "react"
+import { AppStateStore } from "./AppStateStore"
 import { Article } from "./Article"
 import { GlobalHeader } from "./GlobalHeader"
-import { AppState, appStateContext } from "./useAppState"
-import { AppAction, dispatchContext } from "./useDispatch"
+import { appStateContext } from "./useAppState"
+import { dispatchContext } from "./useDispatch"

-export function App() {
-  const [appState, dispatch] = useReducer(
-    (state: AppState, action: AppAction) => {
+const store = new AppStateStore(
+  (state, action) => {
       switch (action.type) {
         case "like": {
           const { articleId } = action.payload

           return {
             ...state,
             articles: state.articles.map((article) => {
               if (article.id !== articleId) {
                 return article
               }

               return {
                 ...article,
                 likes: article.likes + 1,
               }
             }),
           }
         }
       }

       return state
     },
     {
       articles: [
         {
           id: "1",
           contents: "本来は API などから取得する記事の中身",
           likes: 0,
         },
         {
           id: "2",
           contents: "二つ目の記事",
           likes: 0,
         },
       ],
     }
   )

+export function App() {
+  const dispatch = store.dispatch
+  const appState = store.getState()
+
   return (
     <dispatchContext.Provider value={dispatch}>
       <appStateContext.Provider value={appState}>
         <GlobalHeader />

         <Article id="1" />

         <Article id="2" />
       </appStateContext.Provider>
     </dispatchContext.Provider>
   )
 }

これで B の問題は解決したように見えます。しかし、このアプリは実際は動きません。App のステートとして持たせることで、React が appState の変更を検知できるようにしていたのに、appState を検知不能な AppStateStore に押し込めてしまったからです。

強制再レンダリングボタンを用意すると、AppStateStore はステート管理はできているものの、その変更を検知できないだけというのがわかります[2]

App.tsx
 export function App() {
   const dispatch = store.dispatch
   const appState = store.getState()

+  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
+
   return (
     <dispatchContext.Provider value={dispatch}>
       <appStateContext.Provider value={appState}>
+        <button onClick={forceUpdate}>強制再レンダリング</button>
+
         <GlobalHeader />

         <Article id="1" />

         <Article id="2" />
       </appStateContext.Provider>
     </dispatchContext.Provider>
   )
 }

これでは困るので、appState に変更があったら forceUpdate 関数を呼び出すようにしてしまいましょう。

AppStateStore.ts
 import { AppState } from "./useAppState"
 import { AppAction } from "./useDispatch"

 export class AppStateStore {
+  private listeners: (() => void)[] = []
+
   constructor(
     private reducer: (state: AppState, action: AppAction) => AppState,
     private appState: AppState
   ) {}

   getState = (): AppState => {
     return this.appState
   }

   dispatch = (action: AppAction) => {
     this.appState = this.reducer(this.appState, action)
+
+    this.listeners.forEach((listener) => listener())
   }
+
+  subscribe = (listener: () => void) => {
+    this.listeners.push(listener)
+
+    return () => {
+      const index = this.listeners.lastIndexOf(listener)
+
+      this.listeners.splice(index, 1)
+    }
+  }
 }
App.tsx
 export function App() {
   const dispatch = store.dispatch
   const appState = store.getState()

   const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
+  useEffect(() => {
+    const unsubscribe = store.subscribe(forceUpdate)
+
+    return unsubscribe
+  }, [])

   return (
     <dispatchContext.Provider value={dispatch}>
       <appStateContext.Provider value={appState}>
-        <button onClick={forceUpdate}>強制再レンダリング</button>
-
         <GlobalHeader />

         <Article id="1" />

         <Article id="2" />
       </appStateContext.Provider>
     </dispatchContext.Provider>
   )
 }

うまく行きました。

しかしながら、これでは B の問題(appState が一部でも変わると App 配下のコンポーネントはすべて再レンダリングされる)に逆戻りです。これは App で forceUpdate しているのが問題なので、appState を参照したいコンポーネントで個別に forceUpdate すればいいでしょう。そして、App のレンダリングがなくなることで、appStateContext で appState を渡すという技が使えなくなるので(App 内の store.getState() が一度しか呼ばれないということだから)、その点も改善します。

App.tsx
-import React, { useEffect, useReducer } from "react"
+import React from "react"
 import { AppStateStore } from "./AppStateStore"
 import { Article } from "./Article"
 import { GlobalHeader } from "./GlobalHeader"
-import { appStateContext } from "./useAppState"
+import { storeContext } from "./useAppStateStore"
 import { dispatchContext } from "./useDispatch"

# 略

 export function App() {
   const dispatch = store.dispatch
-  const appState = store.getState()
-
-  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
-  useEffect(() => {
-    const unsubscribe = store.subscribe(forceUpdate)
-
-    return unsubscribe
-  }, [])

   return (
+    <storeContext.Provider value={store}>
       <dispatchContext.Provider value={dispatch}>
-      <appStateContext.Provider value={appState}>
         <GlobalHeader />

         <Article id="1" />

         <Article id="2" />
-      </appStateContext.Provider>
       </dispatchContext.Provider>
+    </storeContext.Provider>
   )
 }
GlobalHeader.tsx
-import React from "react"
+import React, { useEffect, useReducer } from "react"
 import { useAppState } from "./useAppState"
+import { useAppStateStore } from "./useAppStateStore"

 export function GlobalHeader() {
+  const store = useAppStateStore()
+
+  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
+  useEffect(() => {
+    const unsubscribe = store.subscribe(forceUpdate)
+
+    return unsubscribe
+  }, [])
+
   const { articles } = useAppState()

   return (
     <header style={{ padding: 16 }}>
       総いいね: {articles.reduce((acc, { likes }) => acc + likes, 0)}
     </header>
   )
 }
useAppState.ts
-import { createContext, useContext } from "react"
+import { useAppStateStore } from "./useAppStateStore"

 export interface AppState {
   articles: {
     id: string
     contents: string
     likes: number
   }[]
 }

-export const appStateContext = createContext<AppState | null>(null)
-
 export function useAppState() {
-  const appState = useContext(appStateContext)
-  if (!appState) {
-    throw new Error("Provider で囲んでください")
-  }
+  const store = useAppStateStore()
+  const appState = store.getState()

   return appState
 }
useAppStateStore.ts
import { createContext, useContext } from "react"
import { AppStateStore } from "./AppStateStore"

export const storeContext = createContext<AppStateStore | null>(null)

export function useAppStateStore() {
  const store = useContext(storeContext)
  if (!store) {
    throw new Error("Provider で囲んでください")
  }

  return store
}

ここまでだと GlobalHeader だけ forceUpdate を仕込んでいるので、動作は次のようになります。Article 内のいいね数が変化しないのが、App が再レンダリングされていないことの証拠です。

Article にも適用しましょう。

Article.tsx
 export function Article({ id }: { id: string }) {
+  const store = useAppStateStore()
+
+  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
+  useEffect(() => {
+    const unsubscribe = store.subscribe(forceUpdate)
+
+    return unsubscribe
+  }, [])
+
   const { articles } = useAppState()
   const dispatch = useDispatch()

# 略

GlobalHeader と同じコードをコピペしているので、カスタムフックにまとめることにします(まとめ先は既存の useAppState ですが)。また、dispatchContext がなくても storeContext から dispatch も取得できることに気づいたので、その点も整理しておきます。

App.tsx
 export function App() {
-  const dispatch = store.dispatch
-
   return (
     <storeContext.Provider value={store}>
-      <dispatchContext.Provider value={dispatch}>
       <GlobalHeader />

       <Article id="1" />

       <Article id="2" />
-      </dispatchContext.Provider>
     </storeContext.Provider>
   )
 }
Article.tsx
 export function Article({ id }: { id: string }) {
-  const store = useAppStateStore()
-
-  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
-  useEffect(() => {
-    const unsubscribe = store.subscribe(forceUpdate)
-
-    return unsubscribe
-  }, [])
-
   const { articles } = useAppState()
   const dispatch = useDispatch()
GlobalHeader.tsx
 export function GlobalHeader() {
-  const store = useAppStateStore()
-
-  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
-  useEffect(() => {
-    const unsubscribe = store.subscribe(forceUpdate)
-
-    return unsubscribe
-  }, [])
-
   const { articles } = useAppState()
useAppState.ts
+import { useEffect, useReducer } from "react"
 import { useAppStateStore } from "./useAppStateStore"

 export interface AppState {
   articles: {
     id: string
     contents: string
     likes: number
   }[]
 }

 export function useAppState() {
   const store = useAppStateStore()
+
+  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
+  useEffect(() => {
+    const unsubscribe = store.subscribe(forceUpdate)
+
+    return unsubscribe
+  }, [])
+
   const appState = store.getState()

   return appState
 }
useDispatch.ts
-import React, { createContext, useContext } from "react"
+import { useAppStateStore } from "./useAppStateStore"

 export type AppAction = {
   type: "like"
   payload: {
     articleId: string
   }
 }

-export const dispatchContext = createContext<React.Dispatch<AppAction> | null>(null)
-
 export function useDispatch() {
-  const dispatch = useContext(dispatchContext)
-  if (!dispatch) {
-    throw new Error("Provider で囲んでください")
-  }
+  const store = useAppStateStore()

-  return dispatch
+  return store.dispatch
 }

これで B の問題は完全解決です。ここまでのコードは次のとおりです。

AppStateStore.ts
AppStateStore.ts
import { AppState } from "./useAppState"
import { AppAction } from "./useDispatch"

export class AppStateStore {
  private listeners: (() => void)[] = []

  constructor(
    private reducer: (state: AppState, action: AppAction) => AppState,
    private appState: AppState
  ) {}

  getState = (): AppState => {
    return this.appState
  }

  dispatch = (action: AppAction) => {
    this.appState = this.reducer(this.appState, action)

    this.listeners.forEach((listener) => listener())
  }

  subscribe = (listener: () => void) => {
    this.listeners.push(listener)

    return () => {
      const index = this.listeners.lastIndexOf(listener)

      this.listeners.splice(index, 1)
    }
  }
}
useAppState.ts
useAppState.ts
import { useEffect, useReducer } from "react"
import { useAppStateStore } from "./useAppStateStore"

export interface AppState {
  articles: {
    id: string
    contents: string
    likes: number
  }[]
}

export function useAppState() {
  const store = useAppStateStore()

  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
  useEffect(() => {
    const unsubscribe = store.subscribe(forceUpdate)

    return unsubscribe
  }, [])

  const appState = store.getState()

  return appState
}
useAppStateStore.ts
useAppStateStore.ts
import { createContext, useContext } from "react"
import { AppStateStore } from "./AppStateStore"

export const storeContext = createContext<AppStateStore | null>(null)

export function useAppStateStore() {
  const store = useContext(storeContext)
  if (!store) {
    throw new Error("Provider で囲んでください")
  }

  return store
}
useDispatch.ts
useDispatch.ts
import { useAppStateStore } from "./useAppStateStore"

export type AppAction = {
  type: "like"
  payload: {
    articleId: string
  }
}

export function useDispatch() {
  const store = useAppStateStore()

  return store.dispatch
}
App.tsx
App.tsx
import React from "react"
import { AppStateStore } from "./AppStateStore"
import { Article } from "./Article"
import { GlobalHeader } from "./GlobalHeader"
import { storeContext } from "./useAppStateStore"

const store = new AppStateStore(
  (state, action) => {
    switch (action.type) {
      case "like": {
        const { articleId } = action.payload

        return {
          ...state,
          articles: state.articles.map((article) => {
            if (article.id !== articleId) {
              return article
            }

            return {
              ...article,
              likes: article.likes + 1,
            }
          }),
        }
      }
    }

    return state
  },
  {
    articles: [
      {
        id: "1",
        contents: "本来は API などから取得する記事の中身",
        likes: 0,
      },
      {
        id: "2",
        contents: "二つ目の記事",
        likes: 0,
      },
    ],
  }
)

export function App() {
  return (
    <storeContext.Provider value={store}>
      <GlobalHeader />

      <Article id="1" />

      <Article id="2" />
    </storeContext.Provider>
  )
}
Article.tsx
Article.tsx
import React from "react"
import { useAppState } from "./useAppState"
import { useDispatch } from "./useDispatch"

export function Article({ id }: { id: string }) {
  const { articles } = useAppState()
  const dispatch = useDispatch()

  const article = articles.find((a) => a.id === id)
  if (!article) {
    return <article>404</article>
  }

  const { contents, likes } = article

  return (
    <article style={{ border: "solid 1px gray", margin: 8, padding: 8 }}>
      <p>{contents}</p>

      <div>この記事のいいね: {likes}</div>

      <button
        onClick={() => {
          dispatch({
            type: "like",
            payload: {
              articleId: id,
            },
          })
        }}
      >
        いいねする
      </button>
    </article>
  )
}
GlobalHeader.tsx
GlobalHeader.tsx
import React from "react"
import { useAppState } from "./useAppState"

export function GlobalHeader() {
  const { articles } = useAppState()

  return (
    <header style={{ padding: 16 }}>
      総いいね: {articles.reduce((acc, { likes }) => acc + likes, 0)}
    </header>
  )
}

再レンダリングをできるだけ抑える(問題 A)

再レンダリングの原因を再掲します:

  • A) appState の全体がコンテキスト経由で渡っているので、appState が一部でも変わると useAppState を使ったコンポーネントがすべて再レンダリングされる。
  • B) そもそも appState が App のステートなので、appState が一部でも変わると App 配下のコンポーネントはすべて(useAppState を使っていようといまいと)再レンダリングされる。

問題 B は、appState を AppStateStore クラスに持たせることで解決したのでした。その過程で appState をコンテキスト経由で渡すことはなくなりましたが、代わりに useAppState を使うコンポーネントは forceUpdate によって、appState が一部でも変わると必ず再レンダリングされるようになっています。問題 A の文前半は当てはまらなくなりましたが、依然として後半部分は残ったまま、つまり問題 A は残ったままです。

問題 A が問題なのは、appState のうち、それぞれのコンポーネントに必要な値は一部だし異なるのに、「appState を参照している」という理由だけで一律レンダリングを強いられることです。なので appState の全体ではなく一部だけ(slice という)を参照するようにして、その一部に変化がなければ forceUpdate を免除するようにできればよさそうです。

具体的には、次のように useAppState を変更すれば実現できます:

useAppState.ts
-import { useEffect, useReducer } from "react"
+import { useEffect, useReducer, useRef } from "react"
 import { useAppStateStore } from "./useAppStateStore"

 export interface AppState {
   articles: {
     id: string
     contents: string
     likes: number
   }[]
 }

+const firstCall = Symbol("firstCall")
+
-export function useAppState() {
+export function useAppState<T>(selector: (state: AppState) => T): T {
+  const selector$ = useRef(selector)
+  useEffect(() => {
+    selector$.current = selector
+  })
+
+  const slice$ = useRef<T | typeof firstCall>(firstCall)
   const store = useAppStateStore()

   const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
   useEffect(() => {
-    const unsubscribe = store.subscribe(forceUpdate)
+    const unsubscribe = store.subscribe(() => {
+      const freshSlice = selector$.current(store.getState())
+      if (freshSlice === slice$.current) return
+
+      slice$.current = freshSlice
+
+      forceUpdate()
+    })

     return unsubscribe
   }, [])

-  const appState = store.getState()
-
-  return appState
+  // 初回だけここで selector を呼ぶ。
+  // 2 回目以降は subscribe したコールバック内で selector を呼んでいるので、呼ぶ必要がない。
+  if (slice$.current === firstCall) {
+    return selector(store.getState())
+  }
+
+  return slice$.current
 }

いろいろ処理が足されていますが、キーとなるのは真ん中あたりの store.subscribe の中の分岐です。

useAppState.ts
const unsubscribe = store.subscribe(() => {
  const freshSlice = selector$.current(store.getState())
  if (freshSlice === slice$.current) return

  slice$.current = freshSlice

  forceUpdate()
})

useAppState の引数の selector[3] によって freshSlice を取得し、それが以前の値 slice$.current と同一ならば forceUpdate をスキップしています。これによって、参照する appState の部分が異なるコンポーネントは、それぞれ独立したレンダリングタイミングを持つことができます。

useAppState のインターフェイス変更に追従しつつ、slice を使ったことによる効果が見えるように GlobalHeader と Article を変更します。

Article.tsx
 export function Article({ id }: { id: string }) {
-  const { articles } = useAppState()
+  const article = useAppState((state) =>
+    state.articles.find((a) => a.id === id)
+  )
   const dispatch = useDispatch()

-  const article = articles.find((a) => a.id === id)
   if (!article) {
     return <article>404</article>
   }

   const { contents, likes } = article

   return (
GlobalHeader.tsx
 import React from "react"
 import { useAppState } from "./useAppState"

 export function GlobalHeader() {
-  const { articles } = useAppState()
+  const articles = useAppState((state) => state.articles)

   return (
     <header style={{ padding: 16 }}>
       総いいね: {articles.reduce((acc, { likes }) => acc + likes, 0)}
     </header>
   )
 }

GlobalHeader は selector を渡している意味はあまりない状態ですが、Article は自分の ID のみを appState から切り出すように改善されています。

結果は次のようになります。それぞれの Article は互いのいいね数には無関心なので、自分のいいね数が変わらないときは再レンダリングされていません:

これで問題 A も解決しました。

最終形のソースコードを載せておきます。

AppStateStore.ts
AppStateStore.ts
import { AppState } from "./useAppState"
import { AppAction } from "./useDispatch"

export class AppStateStore {
  private listeners: (() => void)[] = []

  constructor(
    private reducer: (state: AppState, action: AppAction) => AppState,
    private appState: AppState
  ) {}

  getState = (): AppState => {
    return this.appState
  }

  dispatch = (action: AppAction) => {
    this.appState = this.reducer(this.appState, action)

    this.listeners.forEach((listener) => listener())
  }

  subscribe = (listener: () => void) => {
    this.listeners.push(listener)

    return () => {
      const index = this.listeners.lastIndexOf(listener)

      this.listeners.splice(index, 1)
    }
  }
}
useAppState.ts
useAppState.ts
import { useEffect, useReducer, useRef } from "react"
import { useAppStateStore } from "./useAppStateStore"

export interface AppState {
  articles: {
    id: string
    contents: string
    likes: number
  }[]
}

const firstCall = Symbol("firstCall")

export function useAppState<T>(selector: (state: AppState) => T): T {
  const selector$ = useRef(selector)
  useEffect(() => {
    selector$.current = selector
  })

  const slice$ = useRef<T | typeof firstCall>(firstCall)
  const store = useAppStateStore()

  const [, forceUpdate] = useReducer((v) => v + 1, Number.MIN_SAFE_INTEGER)
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const freshSlice = selector$.current(store.getState())
      if (freshSlice === slice$.current) return

      slice$.current = freshSlice

      forceUpdate()
    })

    return unsubscribe
  }, [])

  // 初回だけここで selector を呼ぶ。
  // 2 回目以降は subscribe したコールバック内で selector を呼んでいるので、呼ぶ必要がない。
  if (slice$.current === firstCall) {
    return selector(store.getState())
  }

  return slice$.current
}
useAppStateStore.ts
useAppStateStore.ts
import { createContext, useContext } from "react"
import { AppStateStore } from "./AppStateStore"

export const storeContext = createContext<AppStateStore | null>(null)

export function useAppStateStore() {
  const store = useContext(storeContext)
  if (!store) {
    throw new Error("Provider で囲んでください")
  }

  return store
}
useDispatch.ts
useDispatch.ts
import { useAppStateStore } from "./useAppStateStore"

export type AppAction = {
  type: "like"
  payload: {
    articleId: string
  }
}

export function useDispatch() {
  const store = useAppStateStore()

  return store.dispatch
}
App.tsx
App.tsx
import React from "react"
import { AppStateStore } from "./AppStateStore"
import { Article } from "./Article"
import { GlobalHeader } from "./GlobalHeader"
import { storeContext } from "./useAppStateStore"

const store = new AppStateStore(
  (state, action) => {
    switch (action.type) {
      case "like": {
        const { articleId } = action.payload

        return {
          ...state,
          articles: state.articles.map((article) => {
            if (article.id !== articleId) {
              return article
            }

            return {
              ...article,
              likes: article.likes + 1,
            }
          }),
        }
      }
    }

    return state
  },
  {
    articles: [
      {
        id: "1",
        contents: "本来は API などから取得する記事の中身",
        likes: 0,
      },
      {
        id: "2",
        contents: "二つ目の記事",
        likes: 0,
      },
    ],
  }
)

export function App() {
  return (
    <storeContext.Provider value={store}>
      <GlobalHeader />

      <Article id="1" />

      <Article id="2" />
    </storeContext.Provider>
  )
}
Article.tsx
Article.tsx
import React from "react"
import { useAppState } from "./useAppState"
import { useDispatch } from "./useDispatch"

export function Article({ id }: { id: string }) {
  const article = useAppState((state) =>
    state.articles.find((a) => a.id === id)
  )
  const dispatch = useDispatch()

  if (!article) {
    return <article>404</article>
  }

  const { contents, likes } = article

  return (
    <article style={{ border: "solid 1px gray", margin: 8, padding: 8 }}>
      <p>{contents}</p>

      <div>この記事のいいね: {likes}</div>

      <button
        onClick={() => {
          dispatch({
            type: "like",
            payload: {
              articleId: id,
            },
          })
        }}
      >
        いいねする
      </button>
    </article>
  )
}
GlobalHeader.tsx
GlobalHeader.tsx
import React from "react"
import { useAppState } from "./useAppState"

export function GlobalHeader() {
  const articles = useAppState((state) => state.articles)

  return (
    <header style={{ padding: 16 }}>
      総いいね: {articles.reduce((acc, { likes }) => acc + likes, 0)}
    </header>
  )
}

ここまでで作ったものはほぼ Redux と React Redux

お気づきでしょうか。恐ろしいことに、ここまで頑張って作ったものはほぼ Redux と React Redux の焼き直しです。しかも本家よりも考慮が足りてないポイントもある(ミドルウェアや型の汎用性やエラー系の処理だけではなく、API ドキュメントやテストが全然ありません)ので、劣化 版です[4]

AppStateStore など、まさに Redux のストアのインターフェイスそのままです(ストアの生成方法や appState の初期値の与え方こそ異なるものの)。

useAppState, useAppStateStore, useDispatch は、それぞれ React Redux の useSelector, useStore, useDispatch に対応します。とくに useAppState に対しては工夫を凝らして問題 A を解決するなどしましたが、useSelector は当然その機能を備え問題 A を回避しています。それどころか、前回と今回の slice の値の同一性を判定する関数をオプションとして渡し、=== 以外の評価で判断することも可能です(たとえば shallowEqual, つまりオブジェクトの第一階層のキー・バリューが同じであれば、オブジェクトの参照自体は異なっても同一とみなすこともできる)。引数の selector が前回と同じ関数を参照するのであれば、selector を呼び出すことすら省略してくれます。

ですので、オレオレ useReducer + useContext を組み込むくらいなら、Redux(と React Redux)を使いましょう。学習コストも同じということがわかりましたし。

なぜ Redux 不要論が出てきてしまうのか

「Redux は難しいし不要!フックで十分!」という論の気持ちはとてもわかるので(今でも、特定の条件で Redux を使わされると感じます)、この主張の背景を考察してみましょう。

connect(mapStateToProps)(TodoList) をやらされた恨みがある

はい・・・。正直あの API は難しいです。フックが出る前に Redux を触っていたころは、大域に connect してしまって(細かく connect していく苦行に耐えられなかった)画面のカクツキを起こすなどしょっちゅう火傷していました。

今はフックがあります。useSelector, useDispatch, そして useStore. フックを使いましょう[5]

Redux のステート設計、アクション設計が難しいと感じている

これはそのとおりだと思います。しかし、その難しさは、useReducer + useContext にしたからといって解決はしません。

この記事の問題 A の解決は、実はグローバルステートの切り出しを局所化する以外にも方法があります。そのひとつが unstated-next のような、ステートの分割による解決です。グローバルステートひとつを全員でいじり倒すから問題になるのであって、ステートを分割すれば影響が局所化できるという思想ですね(MobX もその 2 分類だと unstated-next の仲間です)。

この考えに対して、それでも Redux を使うべきとは思いませんし、Redux を置き換える一方的に優れたものだとも思いません。別のデザイン、選択というだけだからです。

ステートを分割するのは、各コンポーネントが自律的にスマートになっていく、マイクロフロントエンド的な流れにマッチしそうです。一方で、やはり分割されたものを統率する存在というのは必要で、たとえば極端ですが、記事一覧の中で繰り返しコンポーネントがそれぞれ好き勝手にバックエンドからデータを取得しているのは困ると思います。API 呼び出しについてはキャッシュクラスなどがあれば解決するかもしれませんが、全体最適を考えてコントロールしたい箇所は API 呼び出しのみとも限りません。ステートを分割すると、その都度、各コンポーネントの協調方法に頭を悩ませることになるかもしれません。

また、全然領域が異なるからとうまく分割したはずのステートも、レアケースかもしれませんがお互いを参照したいケースは発生しうるんじゃないでしょうか。たとえば会員制ブログで、記事の情報と会員の情報はこれまでまったく独立だったが、プレミアム会員のときは記事の中の広告を除去する要件が入った、というような。ステートを分割していると、会員情報ステートと記事情報のステートを橋渡しするのはコンポーネントということになりますが、これはテストしたい重要なロジックがコンポーネントに埋め込まれるということです。そのトレードオフを意識する必要があります。

グローバルなステートをひとつだけ扱うというのは、その響きに反して意外とワークする手法です。文脈は異なりますが、Tsuyoshi Yamada さんの記事 自分で、でっかいベアメタル(物理)サーバーを作りたい方へ で言われているように、処理を分散させないで 1 台で捌き切るというアナロジーはフロントエンドでも生きるのではと思うのです。

自分は、実体はグローバルなステートでありながら、ソースコードの関心は分割されている、というような設計ができるようになってきた手応えがあるため、グローバルなステート 1 つで戦う Redux スタイルを好んでいます。

Redux Toolkit を使っている

最近の発見なのですが、Redux Toolkit を使うと Redux は 面倒 になります(好き嫌いを反映しすぎているので中立的に言うと、「学習コストが高く」なります)。記事冒頭で Redux 単体は 学習コストが高くないと強調したのはそのためです。この Redux Toolkit が Redux 公式トップにあるのは、誰かが仕込んだネガティブキャンペーンなのではと疑いたくなるレベルです。

何が気に食わないかというと、シンプルなはずの Redux にいろいろルールが加わる点です。例えるなら、衣類の洗濯なんて(本人がいいなら)洗濯機に対象物と洗剤を入れてスイッチを押すだけのはずが、こだわり症の同居人が予洗いだ襟袖の処理だネットに入れるだ柔軟剤だと制約を課してくるような。最終的に手順は複雑化するかもしれませんが、最初からそれじゃあ洗濯入門できないですよね。

洗濯ではなく Redux Toolkit の具体的な点で指摘していくと

  • combineReducer を使うのでステートがストア内で分断されてしまう(早すぎる 最適化
  • actionCreator を使うのでアクションのディスパッチが冗長な儀式に見える(関数呼んでディスパッチ用オブジェクト作るならその関数がディスパッチもしてくれないだろうか)
  • actionCreator が Redux Thunk のアクションになって非同期処理を混ぜ込めてしまう
  • reducer も独特の書き方をするので、「state を受け取って state を返しさえすればいい」という点がわかりづらくなる

これは Redux Toolkit を使っていなくとも、巷のチュートリアルでよく見かける手法でもあります。

とくに、非同期処理を混ぜ込めてしまうというのは大きな問題だと思っています。ただのステートの箱だったはずの Redux ストアが、非同期処理(API 呼び出し)はストア様にお願いの儀式をしないといけない、といった状態に祀られてしまうからです。

Redux ストアは、本質的には useState や useReducer と同じだったはずです。useState のときは useState に非同期処理をしてほしいと思わないのに、Redux ストアにはそういうことを望んでしまうのです。useState を使うときの非同期処理の置き場は、コンポーネントの onClick などのハンドラーか useEffect の中だったはずで、Redux ストアでもそれは同じです。

そういう意味で、オレオレ 劣化 Redux の「ミドルウェアが使えない」という点は、デザインとしてかなりの改善かもしれません。

TypeScript ではなく JavaScript で書いている

dispatch に渡すアクションの型に制約がかけられないので、タイポなどの単純ミスを防ぐため actionCreator が使われるようになります。そうすると Redux Toolkit と同じ沼にどんどんはまっていくのでしょう。

いろいろ理解したうえで言っている

mizchi さんの記事 React Context を用いた簡易 Store で紹介されている手法などは、まさに問題 A を解決する前のこの記事の姿です。「問題 A みたいなことはあるが、そこまで気にしないときに使う」という意味の「簡易」ということだと思います。いいんじゃないでしょうか(そして彼の記事は別に「不要論」というわけではないですね)。

※ 当初は、この簡易 Store には問題 B もあると書いていましたが、誤りですので訂正します。Children として配下のコンポーネントを渡しているので、AppStateProvider のレンダリングが配下のコンポーネントの再レンダリングを引き起こすとは限りませんでした。

最後に

個人的な好き嫌いを連ねてしまいました(とくに Redux Toolkit に対し)が、そんなことない、こういう方法・考え方がある、という場合は教えていただけると大変ありがたいです。

Redux はシンプルなツールです。まずは裸のまま使いごごちを確かめてみてください。

脚注
  1. そういう動機から新しい仕組みを提案・公開する試み自体は尊いものです。ただ、worse Redux にとどまるものが多い感触があって、作者や周囲がそれに気づけないまま広まっていくのは問題と思います。 ↩︎

  2. アウトラインが点滅するのは、React Developer Tools のレンダリング検知機能によるものです。 ↩︎

  3. selectorselector$ という ref に入っているのは、useEffect の第二引数に列挙されるのを回避するためです。 ↩︎

  4. あえて機能性を絞るデザインもあるため、ミドルウェアの不在は必ずしも劣位ではありません。しかし、API ドキュメントやテストの不在は、劣っていると言わざるをえません。 ↩︎

  5. レガシーなので React のバージョンを上げられない?それは別の問題なのでは・・・。そしてそういうときは、確かに Redux を捨てるのも選択肢かもしれません。 ↩︎

この記事に贈られたバッジ