React Hooksの登場でやや影の薄くなりつつあるHOC(Higher Order Component)ですが、まだまだ登場機会がなくなった訳ではありません。
TypeScriptで書くHOCは型定義のシンタックスがごちゃごちゃしてわかりづらくなりがちなので、「英語・日本語出し分け用のHOCを作ってみる」というケースをイメージしつつ、改めて整理してみました。
なお、returnを完全に省略したときのHOCのシンタックスの一見さんお断り感は異常なので、適宜returnを残した状態の記述にしています。
やりたいこと
コンポーネントに言語設定を適用したい。
具体的には、アプリケーションのトップレベルで指定されているlang
というpropsを任意のコンポーネントから参照して、'en'
のときと'ja'
のときとで表示するテキストを変更するなどしたい。
作戦
LangProvider
というコンポーネントを用意し、アプリケーションのトップレベルで使用する。
withLang
というHOCを用意し、この引数に与えられたコンポーネントからはlang
の値を参照できるようにする。
使用イメージ
まず始めに、withLang
(とLangProvider
)の使用方法をイメージしてみます。
const App = () => (
<LangProvider lang='ja'>
<Hello />
</LangProvider>
)
export const Hello: React.FC<LangProps> = withLang(({ lang }) => {
const text = lang === 'ja' ? 'こんにちは、世界。' : 'Hello, world.'
return (
<div>
<h1>{text}</h1>
</div>
)
})
こんな風に、withLang
というHOCの引数に与えられたコンポーネントでは、LangProvider
で指定したlang
にアクセスできるようになる、という算段です。
今回はあまり関係ありませんが、一応ディレクトリ構成も書いておきます。
src
├── App.tsx
├── components
│ ├── Hello.tsx
│ └── index.ts
├── index.tsx
└── lang
├── index.ts
├── lang.tsx
└── withLang.tsx
コードを書いてみる
Step1: LangProvider
を用意する
lang.tsx
内に、LangProvider
コンポーネント(と諸々の型定義)を作ります。
import React from 'react'
export interface LangProps {
lang?: 'en' | 'ja'
}
const defaultLang: LangProps = {
lang: 'en',
}
const setLang = (lang: LangProps = {}): LangProps => ({
...defaultLang,
...lang,
})
export const LangContext = React.createContext<LangProps>(setLang())
export const LangProvider: React.FC<LangProps> = ({ children, ...props }) => {
return (
<LangContext.Provider value={setLang({ ...props } || {})}>
{children}
</LangContext.Provider>
)
}
普通にProviderを作っているだけなので、特に解説はありません。
lang
を引数として与えなかった場合はデフォルトで'en'
になるんだな程度の理解でOKです。
Step2: withLang
を用意する
次に、今回のポイントであるwithLang
を用意しましょう。
コードを書き始める前に、もう一度withLang
の最終的な使用イメージを確認しておきます。
(前述と同じコード)
const Hello: React.FC<LangProps> = withLang(({ lang }) => {
const text = lang === 'ja' ? 'こんにちは、世界。' : 'Hello, world.'
return (
<div>
<h1>{text}</h1>
</div>
)
})
withLang
の引数はFunctional Componentで、返り値もFunctional Componentということになります。
では、withLang
を書いていきましょう。
素直に書いていくと、こんな感じになるのではないでしょうか。(このコードには改善の余地があります。後述。)
import React from 'react'
import { LangContext, LangProps } from './lang'
export const withLang = (WrappedComponent: React.ComponentType<LangProps>) => {
return (): React.ReactElement => (
<LangContext.Consumer>
{({ lang }: LangProps): React.ReactElement => <WrappedComponent lang={lang} />}
</LangContext.Consumer>
)
}
withLang
の引数となるFunctional ComponentをWrappedComponent
という名前でとり、返り値としてFunctional Componentを返しています。
返り値のFunctional Component内では引数のコンポーネント(=WrappedComponent
)をLangContext.Consumer
でラップし、lang
にアクセスできるようにした上でpropsとして渡しています。
これで<LangProvider>
にlang='ja'
のように引数を渡せば、Hello
コンポーネント内でもlang
を参照できるようになりました。
ちなみにReact.ComponentType<P>
というのは、ComponentClass<P> | StatelessComponent<P>
の型で、要するにClass ComponentとFunctional Componentのどちらも解決できるという型です。
Step3: withLang
を修正する
さて、前述の状態だと、実は問題が一つあります。
それは、HOCがネスト状態になった時に、propsが正しく渡されないという問題です。
どういうことでしょうか。
例えば、withNightMode
のようなHOCを新たに用意して、ナイトモードの場合デザインが変わるような機能をコンポーネントに与えたいとします。
最終的な使用イメージとしては、こんな感じです。
const HelloComponent: React.FC<HelloProps> = ({ lang, nightMode }): React.ReactElement => {
// nightModeがtrueのときとfalseのときで背景のグラデーションを変える
const background = nightMode
? 'linear-gradient(45deg, #485563, #29323c)'
: 'linear-gradient(45deg, #fe8c00, #f83600)'
const text = lang === 'ja' ? 'こんにちは、世界。' : 'Hello, world.'
return (
<div style={{ background }}>
<h1>{text}</h1>
</div>
)
}
export const Hello = withNightMode(withLang(HelloComponent)) // withNightModeにwithLangを渡している
先ほど作ったwithLang
を、さらにwithNightMode
に渡すことで、最終的に返されるコンポーネントからlang
、nightMode
のどちらにもアクセスしようとしています。
withNightMode
の実装は省略しますが、withLang
とほぼ同じだと思ってください。
そしてApp.tsx
内で下記のようにNightModeProvider
のコンポーネントを使うと、一見<Hello />
にnightModeが適用されそうな気がします。
const App: React.FC = () => {
return (
<NightModeProvider nightMode={true}>
<LangProvider lang={'ja'}>
<Hello />
</LangProvider>
</NightModeProvider>
)
}
しかし実際は、この状態だとHello
コンポーネント内でアクセスできるnightMode
はundefined
となってしまいます。
その理由は、withNightMode
によって渡されるpropsがwithLang
内では無視され、末端のコンポーネントには渡されていないからです。
というのも、withLang
の実装をもう一度見てみると、lang
こそWrappedComponent
へのpropsとして渡されているものの、それ以外のpropsに関する記述はありません。
(前述と同じコード)
import React from 'react'
import { LangContext, LangProps } from './lang'
export const withLang = (WrappedComponent: React.ComponentType<LangProps>) => {
return (): React.ReactElement => (
<LangContext.Consumer>
{({ lang }: LangProps): React.ReactElement => <WrappedComponent lang={lang} />}
</LangContext.Consumer>
)
}
つまり、withNightMode
のHOCによってnightMode
のpropsがwithLang
に渡されても、withLang
的にはそんなこと知らないよ、ということですね。
そこで、withLang
自体に渡されるprops(今回で言えばnightMode
)もWrappedComponent
に渡されるように、withLang
を修正していきます。
完成系はこうなります。
import React from 'react'
import { LangContext, LangProps } from './lang'
export const withLang = <OriginalProps extends {}>(
WrappedComponent: React.ComponentType<OriginalProps & LangProps>
) => {
return (props: OriginalProps): React.ReactElement => (
<LangContext.Consumer>
{({ lang }: LangProps): React.ReactElement => (
<WrappedComponent lang={lang} {...props} />
)}
</LangContext.Consumer>
)
}
ややシンタックスがわかりづらいので、分けて説明します。
まずは前半部分。
export const withLang = <OriginalProps extends {}>(
WrappedComponent: React.ComponentType<OriginalProps & LangProps>
) => {
// 略
}
前半の説明としては、
-
withLang
に渡されるpropsの型をOriginalProps
というジェネリックで指定。 - 最終的に返すコンポーネントのpropsの型に、
OriginalProps
をintersection形式で追加
ということになります。
つまり、今回でいえば**withNightMode
から渡されるpropsをwithLang
内でOriginalProps
型(の一つの値)として受け取り、それを最終的に返すコンポーネント(WrappedComponent
)に含めるように型を指定している**、ということですね。
ちなみに<OriginalProps extends {}>
のextends {}
は、特に機能的な役割を果たしている訳ではなく、「JSXのシンタックスではないよ」というのを指示するための決まり文句のようなものです。
次に、後半部分です。
// 略
return (props: OriginalProps): React.ReactElement => (
<LangContext.Consumer>
{({ lang }: LangProps): React.ReactElement => (
<WrappedComponent lang={lang} {...props} />
)}
</LangContext.Consumer>
)
// 略
ここでは、
- 実際にpropsを受け取る
- 最終的に返されるコンポーネントにpropsを渡す
という作業を追加しています。
今回のケースで言えば、nightMode
などのpropsを受け取り、それをそのままコンポーネントに渡している({...props}
)、ということですね。
上記のような作りにすることで、HOCがネストされても、全てのpropsが末端のコンポーネントまで正しく渡されるようになります。
これで完璧なHOCの完成です。
ソースコード全容
ソースコードはhttps://github.com/Tokky0425/ts-hocに置いてあるので、全体を見たい場合はこちらから見てください。
今回のようなProviderを使ったパターンの場合はReact.useContext
を使ってHooksで実装した方がシンプルかつ綺麗に書けますが、styled-componentsを使うときなど、あえてpropsとして何かしらの値を受け取りたい場合はHOCが便利です。
以下、基本的なパターンチートシート
TypeScriptでの基本的なHOCの雛形は下記のようになります。
Enhancerパターン
「とりあえずpropsを増やす(拡張する)」というパターンです。
例えば、loading
などのように、他の場所でも同じ型定義で使い回しそうなpropsを追加したいときに便利です。
interface EnhencedProps {
someProp: any
}
export const withSomething = <OriginalProps extends {}>(
WrappedComponent: React.ComponentType<OriginalProps & EnhencedProps>
) => {
return (props: OriginalProps & EnhencedProps) => <WrappedComponent {...props} />
}
Injectorパターン
目的のコンポーネントに任意のpropsを注入できるようにするパターンです。
基本的な仕組みとしてはEnhancerパターンと同じで、propsの枠だけ与えるか、値も与えちゃうかだけの違いです。
記事内で実装したものも、Providerを用いているため若干構造が雛形とは違いますが、カテゴリ的にはこちらですね。
react-reduxのconnect
関数でお馴染みですが、withSomething({someProp: someValue})(TargetComponent)
のように引数を取れるようにもできるのがポイントです。
(雛形のコードは引数を取るパターンにしています)
interface ExternalProps {
someProp: any
}
interface InjectedProps {
someFunction: any
}
export const withSomething = ({ someProp }: ExternalProps) => {
return <OriginalProps extends {}>(
WrappedComponent: React.ComponentType<OriginalProps & InjectedProps>
) => {
// whatever
const someFunction = () => {
console.log(someProp)
}
return (props: OriginalProps) => <WrappedComponent someFunction={someFunction} {...props} />
}
}
上記のコードではsomeFunction
という関数をpropsに渡すようなHOCになっていますが、使い方としては自由なのでなんでもできます。
所感
できるだけHooksで済ませよう。