ElectronのIPCをReact Hooksを使って、コンポーネント間で使い回す

Electronでアプリを作るときに、React Hooksを使ってプロセス間通信のロジックをコンポーネント間で使い回せると楽しそうだなと思って、やってみました。

# 動機

Reactのコンポーネント内で非同期通信を扱おうとすると、関連するロジックがコンポーネントのライフサイクルを扱うメソッド内に散らばります。

```JSX
class SomeComponent extends React.Component {
  componentDidMount() {
    ipcRenderer.on(onShowSaveDialogComplete, this.onSave)
  }

  componentWillUnmount() {
    ipcRenderer.removeListener(onShowSaveDialogComplete, this.onSave)
  }

 onSave(event, message) {
    this.setState({ message })
  }
}
```

これは2つの点でイマイチです。

1. ロジックが別のコンポーネントで使い回しにくい
2. 別のステートを扱う非同期通信を同じコンポーネントに追加すると、ライフサイクルメソッド内に無関係のロジックが同居することになる（扱うステートが増えるほど、可読性が下がる）

Hooks APIを使えば、ロジックを一箇所にまとめた上で、簡単に使い回せるようになります。

参考: [フックの導入（https://ja.reactjs.org/docs/hooks-intro.html）](https://ja.reactjs.org/docs/hooks-intro.html)

# 環境構築

## ElectronでReactを使う

Electron内でReactを使うための環境構築は結構めんどくさい（と思う）のですが、以下のページで紹介されていた方法は比較的お手軽でした。

[How to build a React based Electron app](https://medium.com/@impaachu/how-to-build-a-react-based-electron-app-d0f27413f17f)

## 事前準備

Electronのバージョン6ではデフォルトでnodeIntegrationがfalseなので、preload機能で、ipcRendererをwindowに渡しておきます。

```Js:public/preload.js
const { ipcRenderer } = require('electron')
window.ipcRenderer = ipcRenderer
```

```Js:public/main.js
mainWindow = new BrowserWindow({
  webPreferences: {
    nodeIntegration: false,
    preload: `${__dirname}/preload.js`,
  },
})
```

# 実践

ここでは例として、メインプロセス側でdialog.showSaveDialogを呼び出します。

1. inputボックスにテキストを入力
2. Saveボタンをクリックする
3. レンダラープロセスからメインプロセスにテキストを送信
4. メインプロセスでdialog.showSaveDialogを呼び出し
5. 指定されたファイルにテキストを保存
6. メインプロセスからレンダラープロセスに結果を返却

## channel用に定数を用意

```js:share/channels
const Channels = {
  showSaveDialog: 'showSaveDialog',
  onShowSaveDialogComplete: 'onShowSaveDialogComplete',
}
module.exports = Channels

```

## ipcMainのセットアップ

```js:main/setupIpc.js
const { dialog, ipcMain } = require('electron')
const fs = require('fs')
const Channels = require('../shared/channels')

const { showSaveDialog, onShowSaveDialogComplete } = Channels

module.exports = function setupIpc() {
  // showSaveDialogのメッセージを受け取る
  ipcMain.on(showSaveDialog, async (event, arg) => {
    const { text, options } = arg
    // ダイアログを開いて、結果を受け取る   
    const { canceled, filePath } = await dialog.showSaveDialog({
      ...(options ? options : {}),
    })

    let error
    // 入力とpathをチェックして、ファイルに保存する
    if (text && !canceled && filePath) {
      error = await new Promise(resolve => {
        fs.writeFile(filePath, text, err => resolve(err))
      })
    } else { // 入力が不正のときは、エラーを返す
      error = new Error(
        `Invalid input or operation: ${JSON.stringify({
          text,
          canceled,
          filePath,
        })}`
      )
    }

    // 結果をrendererに返却
    event.reply(onShowSaveDialogComplete, {
      // Error型のままではrenderer側で受け取れなかったので、messageを返す
      errorMessage: error ? error.message : null,
      filePath,
    })
  })
}
```

## Hooks APIを利用しない場合

ipcRendererのリスナー登録・解除という関連するロジックが、別々の箇所に記載されており、見通しが悪くなりがちです。

```Jsx:src/components/WriteTextToFileClass.jsx
import React, { createRef } from 'react'
import Channels from '../shared/channels'

const { ipcRenderer } = window
const { showSaveDialog, onShowSaveDialogComplete } = Channels

export default class WriteTextToFileClass extends React.Component {
  constructor(props) {
    super(props)
    this.state = { message: null }
    this.onSave = this.onSave.bind(this)
    this.onSaveTextClick = this.onSaveTextClick.bind(this)
    this.inputRef = createRef()
  }

  componentDidMount() {
    ipcRenderer.on(onShowSaveDialogComplete, this.onSave)
  }

  componentWillUnmount() {
    ipcRenderer.removeListener(onShowSaveDialogComplete, this.onSave)
  }

  render() {
    return (
      <div>
        <p>
          <input type="text" ref={this.inputRef}></input>
          <button onClick={this.onSaveTextClick}>Save Text</button>
        </p>
        <p style={{ fontStyle: 'italic', fontSize: '0.5em' }}>
          {this.state.message}
        </p>
      </div>
    )
  }

  onSave(event, arg) {
    const { errorMessage, filePath } = arg
    const message = errorMessage || `Text has been saved successfully to ${filePath}`
    this.setState({ message })
  }

  onSaveTextClick() {
    const text = this.inputRef.current.value
    ipcRenderer.send(showSaveDialog, {
      text,
      options: {
        filters: [
          { name: 'Text File', extensions: ['txt'] },
        ],
      }
    })
  }
}
```

## Hooks APIを利用してリライト

ipcRendererに関連するロジックのみを抽出したカスタムフックを作成します。
関連するステートの初期化・更新、リスナーの登録・解除が一つの関数内で完結します。
Hooksを利用しない場合も、レンダープロップや高階関数コンポーネントを利用すれば、コンポーネントの再利用は可能ですが、ロジックだけを使いたい場合はこちらの方が簡潔です。

```js:src/hooks/useShowSaveDialog.js
import { useCallback, useEffect, useState } from 'react'
import Channels from '../shared/channels'

const { ipcRenderer } = window
const { showSaveDialog, onShowSaveDialogComplete } = Channels

export default function useShowSaveDialog(options = {}) {
  const [filePath, setFilePath] = useState(null)
  const [errorMessage, setErrorMessage] = useState(null)

  const sendMessage = useCallback(
    text => {
      ipcRenderer.send(showSaveDialog, { text, options })
    },
    [options]
  )

  useEffect(() => {
    function handleOnComplete(event, arg) {
      const { errorMessage, filePath } = arg
      setFilePath(filePath)
      setErrorMessage(errorMessage)
    }
    ipcRenderer.on(onShowSaveDialogComplete, handleOnComplete)

    return () =>
      ipcRenderer.removeListener(onShowSaveDialogComplete, handleOnComplete)
  }[])

  return [errorMessage, filePath, sendMessage]
}

```

作成したフックは関数コンポーネント内で下記のように呼び出します。
別のコンポーネントでも同じように呼び出すことで、簡単に使いまわすことができます。

```jsx:src/components/WriteTextToFile.jsx
import React, { useRef } from 'react'
import useShowSaveDialog from '../hooks/useShowSaveDialog'

export default function WriteTextToFile() {
  const [errorMessage, filePath, showSaveDialog] = useShowSaveDialog({
    filters: [
      { name: 'Text File', extensions: ['txt'] },
    ],
  })

  const inputRef = useRef(null)
  const message = errorMessage || (filePath ? `Text has been saved successfully to ${filePath}` : null)

  return (
    <div>
      <p>
        <input type="text" ref={inputRef}></input>
        <button onClick={() => showSaveDialog(inputRef.current.value)}>Save Text</button>
      </p>
      <p style={{ fontStyle: 'italic', fontSize: '0.5em' }}>
        {message}
      </p>
    </div>
  )
}
```

## パフォーマンスについて

関数コンポーネントでHooksを使うことで、レンダーごとにコールバック関数を作るようになりますが、パフォーマンスへの影響はあまりないそうです。

https://ja.reactjs.org/docs/hooks-faq.html#are-hooks-slow-because-of-creating-functions-in-render

# 最後に

Hooks APIやElectronに関しては、まだあまり使い慣れていないので、おかしな点が
あればコメントいただけると嬉しいです。