きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

HOC と RenderProp と Hook の相互変換はどこまで可能か

React で Cross-Cutting-Concern の文脈で語られ比較されるものと言えば HOC と RenderProp と Hook ですね。

実際に開発を行っているとライブラリから提供される機能がそのどれかのみで、プロジェクトとしては別の形で用いたいのでそれらを相互変換する、ということがたまにあります。

  • Hook は便利だけどクラスコンポーネントでは使いづらいので HOC や RenderProp 版が欲しい
  • Hook は便利だけどコンディショナルな箇所や map する箇所ではそのまま使えないので RenderProp が欲しい時もある
  • HOC だと props の型定義が面倒なので RenderProp でサクッと実装したい
  • HOC だと型パラメータが潰されるので RenderProp で回避したい
  • RenderProp だとネストが増えるので HOC で注入したい

...といった需要によるものです。 ただ、実際にやってみると、どうやら変換できなさそうな組み合わせがあることに徐々に気づいてきたので、今回はその感覚を整理するために、シンプルな例を題材にして、相互変換の可能性や変換時のポイントなどを試行錯誤してみたいと思います。

f:id:lightbulbcat:20190727032318p:plain

実際に動作するコードは github においておきました。

共通する型定義

まずは型定義です。foo という何かを提供する MyFeature という機能をコンポーネントから利用可能にするライブラリを考えることにします。

export interface MyFeature {
  foo: string
}

export const myFeature: MyFeature = {
  foo: 'Yes!'
}

// For RenderProp ver.
export interface MyFeatureProps {
  children: (feature: MyFeature) => JSX.Element
}

// For HOC ver.
export type WithMyFeature = { feature: MyFeature }

各パターンを組んでみる

HOC から変換する

ライブラリから HOC として機能が提供される場合、それを RenderProp や Hook として変換できないか試してみます。

import React, { createElement } from 'react'
import { myFeature, WithMyFeature, MyFeatureProps } from './my-feature'

// ライブラリから HOC として機能が提供
export function withFeatureFromHoc<P extends {}>(C: React.ComponentType<P & WithMyFeature>) {
  return (props: P) => <C feature={myFeature} {...props} />
}

// HOC → RenderProp (*˘꒳˘*) ちょっときもちわるい...
export function FeatureFromHoc(props: MyFeatureProps) {
  return createElement(withFeatureFromHoc(({ feature }) => {
    return props.children(feature)
  }), props)
}

// HOC → Hook (*˘꒳˘*) かけない...
export function useFeatureFromHoc() {
  return null
}

残念ながら Hook はうまく組めませんでした。

HOC → RenderProp も動きはするのですが、コードがちょっと不安になりますね。

RenderProp から変換する

ライブラリから RenderProp として機能が提供される場合、それをHOC や Hook として変換できないか試してみます。

import React from 'react'
import { myFeature, MyFeatureProps, WithMyFeature } from './my-feature'

// ライブラリから RenderProp として機能が提供
export function FeatureFromRenderProp(props: MyFeatureProps) {
  return props.children(myFeature)
}

// RenderProp → HOC (*˘꒳˘*) すんなり
export function withFeatureFromRenderProp<P extends {}>(C: React.ComponentType<P & WithMyFeature>) {
  return (props: P) => (
    <FeatureFromRenderProp>
      {feature => <C feature={feature} {...props} />}
    </FeatureFromRenderProp>
  )
}

// RenderProp → Hook (*˘꒳˘*) かけない...
export function useFeatureFromRenderProp() {
  return null
}

RenderProp → HOC への変換はかなり自然に書けている気がします。

これも残念ながら Hook はうまく組めませんでした。

Hook から変換する

最後に、ライブラリから Hook として機能が提供される場合、それを HOC や RenderProp として変換できないか試してみます。

import React, { useMemo } from 'react'
import { myFeature, MyFeatureProps, WithMyFeature } from './my-feature'

// ライブラリから Hook として機能が提供
export function useFeatureFromHook() {
  return useMemo(() => myFeature, [])
}

// Hook → HOC (*˘꒳˘*) すんなり
export function withFeatureFromHook<P extends {}>(C: React.ComponentType<P & WithMyFeature>) {
  return (props: P) => {
    const feature = useFeatureFromHook()
    return <C feature={feature} {...props} />
  }
}

// Hook →RenderProp (*˘꒳˘*) すんなり
export function FeatureFromHook(props: MyFeatureProps) {
  const feature = useFeatureFromHook()
  return props.children(feature)
}

Hook → HOC or RenderProp の変換は双方ともすんなり書けている気がします。

Hook 化で試したこと

無理やり組めばいけるんじゃないか、と思いクロージャを利用したアクセスで createElement 時に値の参照を得るコードを書いてみました。

export function useFeatureFromHoc() {
  let extractedFeature: MyFeature | undefined
  createElement(withFeatureFromHoc(({ feature }) => {
    extractedFeature = feature
    return null
  }))
  if (!extractedFeature) throw new Error('Feature unextracted')
  return extractedFeature
}

しかしこの Hook をコンポーネント内で評価してもエラーが投げられるばかりで値は得られませんでした。 こんな感じで何とかこねくり回せばいける気がしなくはないのですが...

まとめ

  • HOC → RenderProp: OK?, Hook: 無理
  • RenderProp → HOC: OK, Hook: 無理
  • Hook → HOC: OK, RenderProp: OK

Hook は RenderProp に変換可能で、それらは HOC に変換可能です。 変換の柔軟さでいうと Hook > RenderProp > HOC という感覚です。

  • 一応 HOC を RenderProp 化することはできる
    • でも書いていてちょっと不安になるコードになる
  • RenderProp と HOC を Hook に変換する方法は見つからなかった
    • なので Hook が提供されていないライブラリをの機能を自前で Hook に変換する、といったことはできなさそう、少なくともすんなりとは...
    • 例えば react-router の withRouter とかをちょちょっとやって useRouter できた、みたいなことはできなさそう
  • プロジェクト内で機能の切り出しを考える際は、まず Hook として切り出しておけば、RenderProp や HOC などの使い方をしたい場合にも数行のコードを追加すれば対応可能

仕事でコードを組んでいると複雑で「これ変換できるのかな...」としばらく試行錯誤することが多いのですが、一度シンプルな例で試しておくと、変換のパターンとその要所が把握できて多少理解が整理されますね。

でも正直このあたりの変換可能性はまだ全然自分の中で固まったイメージができていません。 なぜ変換可能性のグラフがこのようになるのか、の部分がです。

ものすごくふんわりした感覚でいうと Hook という枠組みは React Component の外にあり、そして React では一旦 Component の内部に入ってしまった値は外に出しにくい、という感覚があります。 React って動作原理は単純なのですが、その上で現れてくる使用感には単純にその動作原理に帰結させづらいものが多いです。 シンプルな枠組みの上に構築される多相な世界、という感じは数学とも似たところがありますね。

こういうところが React の面白さですね。