😽

Reactのpropsからhooksを差し込んでコンテナにする方法を考えてみた

2022/02/12に公開

はじめに

Reactのちょっとしたコンポーネントを宣言的に管理するために一々コンテナを作って切り出すという作業によって、ファイル数が増加するのと単純なUIにも関わらずメンテコストが増加することが煩わしかったので、コンポーネント側からhooksを突っ込める、コンテナとして使える、そして、宣言的に書かずともロジックを直接書いて切り出せる仕組みを考えたので運用してみた。

割とこれが便利だったのでDevに投稿した記事を日本語でも書こうかなと。

この仕組みを導入できるようにするライブラリがこちら

HOCでexportし直さないといけないのが煩わしいので、もしReact本体をいじらなくてもrefみたいな特殊なpropsを生やせる方法を知っている方がいたらPRお願いします。これがなるべく実装にコストをかけずに思いついた僕の能力の限界でした。

どうすんの?

利用するComponentにinnerHooksというpropを生やします。
その中に関数を突っ込むと子コンポーネントのスコープでhooksが実行します。
innerPropsの戻り値のオブジェクトを元のpropsの一部とマージして、子コンポーネントが要求するpropsと一致するようにします。

そして、この仕組みをwithInnerHooksという材料を導入し、冷蔵庫で1晩寝かせて出来あがったものがこちらになります。

import { useCallback, useEffect, useState } from 'react'
import NumberInput from './components/NumberInput'
import Timer from './components/Timer'
import { useStateFactory, withInnerHooks } from 'react-inner-hooks-extension'

const Input = withInnerHooks((props) => (<input {...props}/>))

function App() {
  const [state, usePartialState] = useStateFactory({
    num: 0,
  })

  return (
    <div className="App">
        <Input
	  type="number"
          innerHooks={() => {
            const [value = 0, setValue] = usePartialState('num')
            return {
              value,
              onChange: (e) => {
                setValue(Number(e.target.value))
              }
            }
          }}
        />
    </div>
  )
}

export default App

ここでは詳しい説明は省くがuseStateFactoryは指定した名前でいい感じにuseStateを生成してくれて、生成時のstateで生成したstateを全て参照してくれるナイスなAPIである。

これを実行すると中間コンポーネントがtypeとinnerHooksの戻り値のオブジェクトをマージして子コンポーネントに送り直す。useSelectorやuseDispatchなどもinnerHooksの中で使えるので、これから派生するパラメータをマッピングする場合はまさにreduxのconnect関数の挙動を再現させているだけにすぎない。定義したコンポーネントにその場でコンテナを突っ込める仕組みができた事になる。

何が便利なの?

コンテキストを利用したいコンポーネントに使うと便利。

        <Input
              type="number"
       innerHooks={() => {
           const [value = 0, setValue] = useContext(NumberUseContext)
           return {
           value,
           onChange: (e) => {
             setValue(Number(e.target.value))
           }
           }
         }
   />

このコードはprovider配下に存在するコンポーネントならどこにコピペしても使える。ほとんどのproviderはコンポーネントツリーのトップレベルに宣言することが多いので、あまり目立ったユースケースはないかもしれないが、世の中には部分的にProviderを利用するライブラリなんかも存在するのでContextを定義したファイル内で、Contextの利用を完結させたい場合なんかに使うと便利。たとえば今までコンテキストを利用したい場合以下のようにProviderを定義する前にhooksを定義するとProviderがまだ生成されていないのでhooks利用時にエラーになる、あるいは、先祖のコンテキストを読んで想定外の挙動をする可能性がある。

const FormContext = createContext({a: 0, b:0})
() => {
    const {a, b} = useContext(FormContext)
  return (
     <FormContext.Provider>
        <Field 
           value={a}
        />
        <Field 
           value={b}
        />
     </FormContext.Provider>
  )
} 

なのでConsumerを使って関数形式で呼び出すしかなかった。

const FormContext = createContext({a: 0, b:0})
() => {
  return (
     <FormContext.Provider>
        <FormContext.Consumer>
          {({a,b}) => (
              <Field 
                 value={a}
              />
              <Field 
                 value={b}
              />
          )}
       <FormContext.Consumer>
     </FormContext.Provider>
  )
} 

これはネストが深くなりがちで括弧の数の打ち間違いが増える、render関数とも区別がつきにくく、スコープを雑に括るとレンダリングしなくていいコンポーネントまで巻き込んでレンダリングされてしまう。No Good。
innerHooksを用いれば以下のように書ける。

const FormContext = createContext({a: 0, b:0})
() => {
  return (
     <FormContext.Provider>
        <Field 
           innerHooks={
              () => {
                 const {a: value} = useContext(FormContext)
                 return {value}
              }
           }
        />
       <Field 
           innerHooks={
              () => {
                 const {b: value} = useContext(FormContext)
                 return {value}
              }
           }
        />
     </Context.Provider>
  )
} 

これで、最適化のためにあちこちConsumerを書く必要がないし必要なところだけでロジックのスコープが閉じているので影響範囲がわかりやすい。何よりネストが浅くて気持ちがいい。見易いは正義。
高々、一回しか使わないコンポーネントのレンダリングを最適化するためにコンポーネントを切り出してコンテナを注入する必要もない。

どうしても宣言的に書かないとReactじゃないという宣言的プログラミングフリークのみなさんのご要望にも応えるために一応宣言的にも書ける例を載せておく。

const hooksContainer = () => {
     const [value = 0, setValue] = useContext(NumberUseContext)
     return {
          value,
          onChange: (e) => {
             setValue(Number(e.target.value))
          }
      }
}
() => (
        <Input
              type="number"
       innerHooks={hooksContainer}
   />
)

注意事項

厳密にはinnerHooksの内部ではルールを守っていれば、ルールは厳格に適用されるような実装になっているが、propsの中でhooksを呼ぶとlinterのルールにはよっては違法になる。
今のところ脱法行為によって利用するしかないので、eslintの拡張ルールの開発を待たれたい。

調子に乗ってみる

調子に乗ってRFCにも出してみた。アクティブではなさそうなので見られてるかどうかは怪しいのであまり期待していないが、進展あったら追っていきたい。

https://github.com/reactjs/rfcs/pull/210
https://github.com/reactjs/rfcs/pull/211

追記

usePartialState系のAPIはReact本体でリジェクトされたアンチパターンらしいことを教えてもらった。

https://overreacted.io/why-do-hooks-rely-on-call-order/

とはいえ、Typescriptによって、valueの違いなどを指定ミスなどを防げるのでJSでのReactユーザーも考慮している点とReactに無駄なものを載せないという観点でいくつかのRejectされたプロポーザルは個人的には有用に使えるものが存在していると思っているので拡張ライブラリとしての意義は無くなっていないかなと考えています。結構便利に使えている印象。

この提案Devコミュニティではそもそもhooksで解決してる問題を逆登りしているように思われたことと、hooksのルールを妨害しない作りになっているがhooksのルールを侵害しているように見えるようで評判が悪かった。

ただし、コミュニティのユーザーが認知していないようなユースケースもありそうなのでサンプルコードを載せているけどもその利用方法に関しては異論が出てこなそうなので安心して使って良いと思う。

また、innerHooksという名前が誤解を生んでいるようで実際のユースケースとしてはprops側から、hooksも使って差し込めるreduxのconnect関数のような使い方を想定してるのでライブラリ側のprop名はinnerHooksからconnectContainerに変更した。

Reactコンポーネントの機能を阻害しない拡張方法に関わらず、毎回HOC書かなきゃいけないのが面倒なので、ここの仕組みはどうにかしたい。
(ライブラリの最新版でjsxを拡張する方法で特定のpropsが存在するときに自動でHOCが挿入されるように実装できた。これについては後日記事にするかもしれない。)

もし、この仕組みについて、フィードバックくれる方がいたらどしどしお願いします。

Discussion