TypeScriptでMaterial-UIコンポーネントをカスタマイズして再利用する

## 動機

Material-UI (MUI) の`Typography`にいちいち`paragraph`属性を書くのが面倒なので、書かなくていいようにしたかった。

## 実例リポジトリ

https://github.com/cu39/example-material-ui-customized-component

## 方法1：テーマでコンポーネントのデフォルトプロパティを設定する

[コメントでkisaragiさんに](https://qiita.com/cu39/items/14232beb8993e362188e#comment-bafc3a9e9ad16b00e999)で教えていただきました。MUIには[テーマでコンポーネントのデフォルトプロパティを設定する機能](https://material-ui.com/ja/customization/globals/#default-props)があり、そこで`paragraph: true`とするとテーマ配下のコンポーネント全体にプロパティが適用されます。

次の例ではテーマオブジェクトに`{props: { MuiTypography: { ... }}`を設定しています。`props`がデフォルトプロパティ設定を表すキー、`MuiTypography`がコンポーネントのスタイル名を表すキーです。このスタイル名は[APIドキュメンテーションのCSSセクション](https://material-ui.com/api/typography/#css)か、直接[ソース](https://github.com/mui-org/material-ui/blob/79fad65eae304d1c3d0e6c492c7ab2368515db7e/packages/material-ui/src/Typography/Typography.js#L248)を参照して調べます。

```jsx:index.tsx
const theme = createMuiTheme({
  palette: {
    primary: cyan,
  },
  props: {
    MuiTypography: {
      paragraph: true,
    },
  },
})

ReactDOM.render(
  <MuiThemeProvider theme={theme}>
    <App />
  </MuiThemeProvider>,
  document.getElementById('root')
)
```

この場合、テーマ配下のコンポーネントで`Typography`で元々の挙動にしたい場合には`paragraph={false}`を与える必要が生じます。影響範囲を限定したい場合には[テーマをネスト](https://material-ui.com/styles/advanced/#theme-nesting)させます。

```jsx:index.tsx
const parentTheme = createMuiTheme({
  palette: {
    primary: cyan,
  },
})

const childTheme = createMuiTheme({
  props: {
    MuiTypography: {
      paragraph: true,
    },
  },
})

ReactDOM.render(
  <MuiThemeProvider theme={parentTheme}>
    <Typography>親テーマの配下</Typography>
    <MuiThemeProvider theme={childTheme}>
      <Typography>親テーマと子テーマの配下</Typography>
    </MuiThemeProvider>,
  </MuiThemeProvider>,
  document.getElementById('root')
)
```

## 方法2：プロパティ設定済みのコンポーネントを作る

デフォルトの挙動も残して区別するなら、コンポーネントを定義して別名を与えるのもいいでしょう。`props.children`を受け取って`Typography`コンポーネントを返すシンプルなコンポーネントを作ってみます。

```tsx:src/ts/Para.tsx
import * as React from 'react'
import Typography, {TypographyProps} from '@material-ui/core/Typography'

const Para = ({children, ...other}: TypographyProps): JSX.Element {
  return (
    <Typography paragraph {...other}>{children}</Typography>
  )
}

export default Para
```

TypeScriptなのでプロパティの型を書いています。TypeScriptでReactを書くとき面倒なポイントのひとつですが、ここでは`@material-ui/core/Typography`パッケージから`TypographyProps`をインポートして使います。

「`Typography`コンポーネントのプロパティ型は`TypographyProps`インターフェース」というように、MUIではコンポーネント名の末尾に`Props`を追加したインターフェースが提供されているので、今回のようにMUIのコンポーネントを拡張したい場合に便利です。

&lt;aside&gt;
ただし`Typography`に関しては罠があって、`@material-ui/system`も[TypographyPropsというtypeをエクスポートしている](https://github.com/mui-org/material-ui/blob/d3a8950b96a83d780aae7c645d64a99485549770/packages/material-ui-system/src/index.d.ts#L183)んですが、こちらは別物。`@material-ui/system/TypographyProps`は[MUIのコンポーネント一般に備わっているタイポグラフィAPI](https://material-ui.com/system/typography/)のプロパティ型で、`Typography`コンポーネント用ではありません。VSCodeのサジェストを信じてうっかりこちらをインポートするとハマります（ハマりました）。まぎらわしい。
&lt;/aside&gt;

さらに`TypographyProps`は`@material-ui/core`からはエクスポートされていません。`@material-ui/core/Typography`から、きちんとコンポーネント単位でインポートする必要があります。まとめると以下。

```ts
// オッケー！
import Typography, {TypographyProps} from '@material-ui/core/Typography'
```

```ts
// ダメ！
import Typography from '@material-ui/core/Typography'
import {TypographyProps} from '@material-ui/system'
```

```ts
// ダメ！
import {Typography, TypographyProps} from '@material-ui/core'
```

別コンポーネントにする場合でも子テーマを使うこともできます。今回くらいのユースケースでは冗長な気もしますが、規模が大きくなると使いどころが出てくるかもしれません。

```tsx
const childTheme = createMuiTheme({
  props: {
    MuiTypography: {
      paragraph: true,
    },
  },
})

const Para = ({children, ...other}: TypographyProps): JSX.Element (
  <MuiThemeProvider theme={childTheme}>
    <Typography {...other}>{children}</Typography>
  </MuiThemeProvider>
)
```

## 発展：スタイルを当てる

せっかくカスタマイズするのだからスタイルも設定したい。ということでそれぞれ方法を見てみます。

### テーマでスタイルを設定

テーマオブジェクトでは[スタイルを設定する](https://material-ui.com/ja/customization/globals/#css)こともできます。

`override`というキーの下にコンポーネントのスタイル名を指定し、さらにコンポーネントごとに決められたルール名を指定してスタイルを書きます。スタイル名とルール名は[コンポーネントAPIドキュメンテーションのCSSセクション](https://material-ui.com/api/typography/#css)を参照します。プロパティ設定が特定条件を満たした場合のみ有効になるスタイルなど、柔軟な設定も可能です。

```jsx:index.tsx
const theme = createMuiTheme({
  palette: {
    primary: cyan,
  },
  props: {
    MuiTypography: {
      paragraph: true,
    },
  },
  overrides: {
    MuiTypography: {
      root: {
        backgroundColor: 'lightblue',
      },
    },
  },
})

ReactDOM.render(
  <MuiThemeProvider theme={theme}>
    <App />
  </MuiThemeProvider>,
  document.getElementById('root')
)
```

### MUIのmakeStyles()やstyled()でスタイルを設定

`@material-ui/core/styles`のAPIを使ってみます。次の例では`makeStyles()`を使った`ParaMui`と、`styled()`を使った`ParaStyled`というコンポーネントを作っています。

```tsx:src/ts/Para.tsx
import * as React from 'react'
import Typography, {TypographyProps} from '@material-ui/core/Typography'
import {
  Theme,
  createStyles,
  makeStyles,
  styled,
} from '@material-ui/core/styles'

const styles = {
  marginBottom: '1rem',
  backgroundColor: '#FFAAAA',
  '&:last-child': {
    marginBottom: 0,
    backgroundColor: '#CCCCFF',
  },
}

const useStyles = makeStyles((theme: Theme) => createStyles({
  para: {
    ...styles,
    marginBottom: theme.spacing(4),
    backgroundColor: theme.palette.primary.main,
    color: theme.palette.primary.contrastText,
  },
}))

// with MUI
export const ParaMui = ({children, className, ...other}: TypographyProps) => {
  const classes = useStyles()
  return (
    <Typography className={`${className} ${classes.para}`} {...other}>
      {children}
    </Typography>
  )
}

// with styled-components API
const ParaStyledBase = ({children, ...other}: TypographyProps) => (
  <Typography {...other}>{children}</Typography>
)

export const ParaStyled = styled(ParaStyledBase)(styles)
```

プレーンな（型を指定しない）オブジェクトスタイルのJSSを用意し、`makeStyles()`を使った`ParaMui`ではMUIのテーマAPIを使って上書きしつつ、`useStyles`フック関数を取得しています。

そのフック関数を`ParaMui`コンポーネントで呼び、クラス名を持つオブジェクト`classes`を得て、`Typography`コンポーネントの`className`に指定しています。今回は[テンプレートリテラル](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/template_strings)でクラス名をつなぎましたが、MUIが[clsx](https://www.npmjs.com/package/clsx)に依存しているので、実環境ではclsxを使うところです。

`styled`APIを使った`ParaStyled`は、最初に作った`styles`オブジェクトを渡すだけでスタイルが当たり、`className`プロパティを書く必要もなく、とてもシンプルです。テンプレートリテラルでCSSを書きたい場合などに[styled-components](https://www.styled-components.com/)や[Emotion](https://emotion.sh/)へ移行するのもスムーズでしょう（MUIでも[JSSプラグインでテンプレートリテラルを使えるようにできます](https://material-ui.com/styles/advanced/#string-templates)が、セレクタやネストが使えない制限があるようです）。

ただMUIのデフォルトテーマや`MuiThemeProvider`コンポーネントで与えたテーマを使おうとする（`theme.spacing()`や`theme.palette`など）と、`makeStyles`でやったようなJSSオブジェクト内での利用はできず、コンポーネント内でどうにかする（`useTheme()`フックで`Theme`を受け取る）しかありません。MUIとの統合を重視するとイマイチな印象です。JSS内でテーマはいらないと割り切れるなら、シンプルに書ける魅力はあります。

&lt;aside&gt;
[withStyles()](https://material-ui.com/styles/api/#withstyles-styles-options-higher-order-component)は`classes`プロパティをコンポーネントに渡すだけなので`makeStyles`と同じことになり、[styled()のwithThemeオプション](https://material-ui.com/styles/api/#styled-component-styles-options-component)や[withTheme()](https://material-ui.com/styles/api/#withtheme-component-component)も`theme`プロパティをコンポーネントに渡すものなので、コンポーネント内でカスタマイズすることになります。デフォルトテーマとの統合を重視する場合には、`makeStyles((theme: Theme) => {...})`でテーマを受け取っておいて、もしコンポーネント内でもテーマを使いたいならさらに`useTheme()`フックを使うのが素直に思えるんですが、好みもあるところでしょう。`styled`スタイルでMUIテーマを利用する良いアイデアがあれば教えてください。
&lt;/aside&gt;

## 使用バージョン

詳しくは実例リポジトリの[package.json](https://github.com/cu39/example-material-ui-customized-component/blob/master/package.json)や[yarn.lock](https://github.com/cu39/example-material-ui-customized-component/blob/master/yarn.lock)を参照してください。

    @material-ui/core 4.5.1
    @types/react 16.9.9
    @types/react-dom 16.9.2
    parcel-bundler 1.12.4
    pug 2.0.4
    react 16.11.0
    react-dom 16.11.0
    typeface-roboto 0.0.75
    typescript 3.6.4