11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

TypeScriptで書くHOC 〜OriginalPropsを添えて〜

Last updated at Posted at 2019-09-29

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コンポーネント(と諸々の型定義)を作ります。

lang.tsx
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の最終的な使用イメージを確認しておきます。

(前述と同じコード)

Hello.tsx
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を書いていきましょう。

素直に書いていくと、こんな感じになるのではないでしょうか。(このコードには改善の余地があります。後述。

withLang.tsx
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を新たに用意して、ナイトモードの場合デザインが変わるような機能をコンポーネントに与えたいとします。

最終的な使用イメージとしては、こんな感じです。

Hello.tsx
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に渡すことで、最終的に返されるコンポーネントからlangnightModeのどちらにもアクセスしようとしています。

withNightModeの実装は省略しますが、withLangとほぼ同じだと思ってください。

そしてApp.tsx内で下記のようにNightModeProviderのコンポーネントを使うと、一見<Hello />にnightModeが適用されそうな気がします。

App.tsx
const App: React.FC = () => {
  return (
    <NightModeProvider nightMode={true}>
      <LangProvider lang={'ja'}>
        <Hello />
      </LangProvider>
    </NightModeProvider>
  )
}

しかし実際は、この状態だとHelloコンポーネント内でアクセスできるnightModeundefinedとなってしまいます。

その理由は、withNightModeによって渡されるpropsがwithLang内では無視され、末端のコンポーネントには渡されていないからです。

というのも、withLangの実装をもう一度見てみると、langこそWrappedComponentへのpropsとして渡されているものの、それ以外のpropsに関する記述はありません。

(前述と同じコード)

withLang.tsx
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を修正していきます。

完成系はこうなります。

withLang.tsx
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>
  )
}

ややシンタックスがわかりづらいので、分けて説明します。

まずは前半部分。

withLang.tsx
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のシンタックスではないよ」というのを指示するための決まり文句のようなものです。

次に、後半部分です。

withLang.tsx
// 略
  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で済ませよう。

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?