React Hooksについてと、便利な独自フックのご紹介

# はじめに

この記事では、React Hooksについての簡単なご紹介と、React Hooksを使った便利な独自フックのご紹介をしたいと思います。

# React Hooksとは

フック (hook) は React 16.8 で追加された新機能です。
state などの React の機能を、クラスを書かずに使えるようになります。（[参考：フックの導入 - React](https://ja.reactjs.org/docs/hooks-intro.html)）

ボタンをクリックするとカウントが増えていくコードを、フックを使った場合と使っていない場合で比べてみましょう。

**■ フックを使わない場合**

```tsx
class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}
```

**■ フックを使った場合**

```tsx
import React, { useState } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
```

このように、シンプルな記述で実現できます。

フックが登場するまでは、クラスコンポーネントで状態管理(state)を行うのが一般的でしたが、フックの登場により関数コンポーネントでも状態を扱いやすくなりました。

[フック API リファレンス](https://ja.reactjs.org/docs/hooks-reference.html)をみると、Reactでは以下のフックが用意されています。

- 基本のフック
  - useState
  - useEffect
  - useContext
- 追加のフック
  - useReducer
  - useCallback
  - useMemo
  - useRef
  - useImperativeHandle
  - useLayoutEffect
  - useDebugValue

基本的なフックの一つである、`useState`の使い方をご紹介します。

## [useState](https://ja.reactjs.org/docs/hooks-reference.html#usestate)の使い方

状態（state）の管理にはステートフックを使います。
ステートフックは`useState`を呼び出すことで使用できます。

引数に初期値を指定し、戻り値としてstateとそれを更新するための関数をペアで返します。
例えば、0から始まるカウントをstateとし、ボタンをクリックするごとにstateを更新するような場合、次のように使用します。

```tsx
// count： stateの現在の値
// setCount： stateを更新するための関数
// 0： 初期値
const [count, setCount] = useState(0);

return (
  <div>
    <label>{count}</label>
    <button onClick={() => setCount(count + 1)}>カウントアップ</button>
  </div>
);
```

この他にも、[useEffect](https://ja.reactjs.org/docs/hooks-reference.html#useeffect)や[useContext](https://ja.reactjs.org/docs/hooks-reference.html#usecontext)など便利なフックはありますが、この記事では割愛させていただきます。

## [独自フック](https://ja.reactjs.org/docs/hooks-custom.html)

自分独自のフックを作成することで、コンポーネントからロジックを抽出して再利用可能な関数を作ることが可能です。

独自で作成したフックの中で、使えそうなフックを紹介したいと思います。

# 独自で作成したフック

[SPA + REST API構成のサービス開発リファレンス](https://fintan.jp/?p=5952)で紹介している[コード例（example-chat）](https://github.com/Fintan-contents/example-chat)をもとにします。
このコード例では、[hooks/index.ts ](https://github.com/Fintan-contents/example-chat/blob/42b3cf36d06104f98ad86ca698bae6873d922662/frontend/src/framework/hooks/index.ts)に独自フックをまとめて宣言しています。
※ 言語として[TypeScript](https://www.typescriptlang.org/)を使用しています

## 入力コンポーネント用独自フック

フォームの作成についてはReactから以下のように[ガイド](https://ja.reactjs.org/docs/forms.html#controlled-components)されております。

> HTML では `<input>`、`<textarea>`、そして `<select>` のようなフォーム要素は通常、自身で状態を保持しており、ユーザの入力に基づいてそれを更新します。
> React では、変更されうる状態は通常はコンポーネントの state プロパティに保持され、setState() 関数でのみ更新されます。

そのため、テキストボックスなどの入力値については、`useState`を使用して保持します。
入力コンポーネントの実装では、input要素に渡す属性や関数等、同様の実装をすることが多くなります。
そこで、ステートフックとステート更新をラッピングした独自フックを作成し、各入力コンポーネントの実装コストを下げることが目的となっています。

### useInput

input要素のステートフックとステート更新をラッピングした独自フック。
onChange属性で、値が変わるたびにstateを更新するようになっています。

```ts
export const useInput = (initialState: string = '') : [string, React.InputHTMLAttributes<HTMLInputElement>, React.Dispatch<React.SetStateAction<string>>] => {
  const [value, setValue] = useState<string>(initialState);

  const onChange = (event: React.FormEvent<HTMLInputElement>) => {
    setValue(event.currentTarget.value);
  };

  return [
    value,
    {
      value,
      onChange
    },
    setValue
  ];
};
```

**使い方**

引数： 
- 初期値
戻り値： 
- stateの現在の値
- inputに渡すためのプロパティが設定されたオブジェクト
- stateの更新関数


```tsx
// text: stateの現在の値
// textAttributes: inputに渡すためのプロパティが設定されたオブジェクト
// setText: stateの更新関数
const [text, textAttributes, setText] = useInput('');

return (
  // textAttributesには `value属性`と`onChange属性`が入っている
  // スプレッド構文で展開して属性を一括設定する
  <input type='text' {...textAttributes}/>
);
```

型定義が異なるだけで、ほとんど同じコードとして`textarea`要素用の 「[useTextarea](https://github.com/Fintan-contents/example-chat/blob/42b3cf36d06104f98ad86ca698bae6873d922662/frontend/src/framework/hooks/index.ts#L163-L184)」があります。
詳しくは、ソースコードを参照してください。


### useCheckbox

`input[type=checkbox]`要素のステートフックとステート更新をラッピングした独自フック。
**※単一のチェックボックスの場合に使用**

```ts
export const useCheckbox = (value: string, initialChecked: boolean = false) : [string, React.InputHTMLAttributes<HTMLInputElement>] => {
  const [checked, setChecked] = useState<boolean>(initialChecked);
  const [checkedValue, setCheckedValue] = useState<string>(initialChecked ? value : '');

  const onChange = (event: React.FormEvent<HTMLInputElement>) => {
    setChecked(event.currentTarget.checked);
    if (event.currentTarget.checked) {
      setCheckedValue(value);
    } else {
      setCheckedValue('');
    }
  };

  return [
    checkedValue,
    {
      value,
      checked,
      onChange
    }
  ];
};
```

**使い方**

引数： 
- チェックボックスのvalue属性
- 初期状態でチェックをつけるかどうか {true/false}
戻り値：
- チェックしている値
- チェックボックス要素の属性が設定されたオブジェクト

```tsx
// checkedValue: チェックしている値
// checkboxAttributes: チェックボックス要素の属性が設定されたオブジェクト
const [checkedValue, checkboxAttributes] = useCheckbox('check', false);

return (
  // checkboxAttributesには `value属性`と`onChange属性`と`checked属性`が入っている
  // スプレッド構文で展開して属性を一括設定する
  <input type='checkbox' {...checkboxAttributes}/>
);
```

### useCheckboxes

`input[type=checkbox]`要素のステートフックとステート更新をラッピングした独自フック。
**※複数のチェックボックスがある場合に使用**

```ts
export const useCheckboxes = (choices: string[], initialChecked: string[] = []) : [string[], string[], (value: string) => React.InputHTMLAttributes<HTMLInputElement>] => {
  const [checkedValues, setCheckedValues] = useState<string[]>(initialChecked.filter(v => choices.includes(v)));
  initialChecked.forEach(value => {
    if(!choices.includes(value)){
      Logger.debug('checkbox initialChecked(' + value + ') is not includes choices.');
    }
  });

  const onChange = (event: React.FormEvent<HTMLInputElement>) => {
    const currentTarget = event.currentTarget;
    if (currentTarget.checked) {
      if (!checkedValues.includes(currentTarget.value)) {
        setCheckedValues([...checkedValues, currentTarget.value]);
      }
    } else {
      setCheckedValues(checkedValues.filter(v => v !== currentTarget.value));
    }
  };
  const attributes = (value: string) => {
    const checked = checkedValues.includes(value);
    return {value, onChange, checked};
  };

  return [
    choices,
    checkedValues,
    attributes,
  ];
};
```

**使い方**

引数： 
- チェックボックスの選択肢
- 初期状態でチェックをつける選択肢
戻り値：
- チェックボックスの選択肢
- チェックしている値
- チェックボックスの属性を返す関数（選択肢の値が引数)

```tsx
const [choices, checkedValues, attributes] = useCheckboxes(['a', 'b', 'c'], ['a']);

return (
  {choices.map((choice, index) => (
    <label key={index}>
      <input type="checkbox" {...attributes(choice)}/>
      <span>{choice}</span>
    </label>
  ))}
);
```

### useRadio

`input[type=radio]`要素のステートフックとステート更新をラッピングした独自フック。

```ts
export const useRadio = (choices: string[], initialChecked: string = '') : [string[], string, (value: string) => React.InputHTMLAttributes<HTMLInputElement> ] => {
  const [checkedValue, setCheckedValue] = useState<string>(choices.includes(initialChecked) ? initialChecked : '');
  if(initialChecked && !choices.includes(initialChecked)){
    Logger.debug('radio initialChecked(' + initialChecked + ') is not includes choices.');
  }

  const onChange = (event: React.FormEvent<HTMLInputElement>) => {
    setCheckedValue(event.currentTarget.value);
  };
  // ランダムなname属性を生成する
  const [name] = useState(() => 'radio_' + new Date().getTime().toString(16) + Math.floor(10000 * Math.random()).toString(16));
  const attributes = (value: string) => {
    const checked = value === checkedValue;
    return {name, value, onChange, checked};
  };

  return [
    choices,
    checkedValue,
    attributes,
  ];
};
```

**使い方**

引数： 
- ラジオボタンの選択肢の値
- 初期状態でチェックをつける値
戻り値：
- ラジオボタンの選択肢
- チェックしている値
- ラジオボタンの属性を返す関数（選択肢の値が引数）

```tsx
const [choices, checkedValue, attributes] = useRadio(['a', 'b'], 'a');

return (
  {choices.map((choice, index) => (
    <label key={index}>
      <input type="radio" {...attributes(choice)}/>
      <span>{choice}</span>
    </label>
  ))}
);
```

### useSelect

`select`要素のステートフックとステート更新をラッピングした独自フック。

```ts
export const useSelect = (initialState: string = '') : [string, React.SelectHTMLAttributes<HTMLSelectElement> ] => {
  const [value, setValue] = useState<string>(initialState);

  const onChange = (event: React.FormEvent<HTMLSelectElement>) => {
    setValue(event.currentTarget.value);
  };

  return [
    value,
    {
      value,
      onChange
    }
  ];
};
```

**使い方**

引数： 
- 初期値
戻り値：
- state
- selectに渡すためのプロパティが設定されたオブジェクト

```tsx
const [select, selectAttributes] = useSelect('');

return (
  <select name="hoge" {...selectAttributes}>
    <option value=''/>
    <option value='1'>1</option>
    <option value='2'>2</option>
    <option value='3'>3</option>
  </select>
);
```

### useSelectMultiple

`select(multiple)`要素のステートフックとステート更新をラッピングした独自フック。
**※複数選択可能なselect**

```ts
export const useSelectMultiple = (initialState: string[] = []) : [string[], React.SelectHTMLAttributes<HTMLSelectElement> ] => {
  const [value, setValue] = useState<string[]>(initialState);

  const onChange = (event: React.FormEvent<HTMLSelectElement>) => {
    const options = event.currentTarget.options;

    const selectedValues = [];
    for (let i = 0; i < options.length; i++) {
      if (options[i].selected) {
        selectedValues.push(options[i].value);
      }
    }
    setValue(selectedValues);
  };

  return [
    value,
    {
      value,
      onChange,
      'multiple': true
    }
  ];
};
```

**使い方**

引数： 
- 初期値
戻り値：
- state
- select(multiple)に渡すためのプロパティが設定されたオブジェクト

```tsx
const [select, selectAttributes] = useSelectMultiple([]);

return (
  <select name="hoge" {...selectAttributes}>
    <option value=''/>
    <option value='1'>1</option>
    <option value='2'>2</option>
    <option value='3'>3</option>
  </select>
);
```

## その他のフック

### usePageTitle

SPAではページごとの[title要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/title)が変わらないため、そのtitle要素を設定するフック。

```ts
export function usePageTitle(title?: string): void {
  useEffect(() => {
    if (title) {
      const previousTitle = document.title;
      document.title = title;
      return () => {
        document.title = previousTitle;
      };
    }
  }, [title]);
}
```

**使い方**

`title要素`を変更したい画面で呼び出してください。

```tsx
usePageTitle('ページタイトル');
```

### useDownloader

次のような手順でファイルのダウンロードを行う用のフック。

1. レスポンスボディをBlobオブジェクトへ変換する
2. URL.createObjectURL(blob)でURLを生成する
3. a要素を動的に生成しhref属性に生成したURL、download属性にファイル名を設定する
4. JavaScriptでa要素のclick()を実行する
5. 生成したa要素とURLを破棄する（メモリリークの回避）

```ts
export function useDownloader(): (blob: Blob, filename: string) => void {
  const download = (blob: Blob, filename: string) => {
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement('a');
    anchor.href = url;
    anchor.download = filename;
    document.body.appendChild(anchor);
    anchor.click();
    URL.revokeObjectURL(url);
    document.body.removeChild(anchor);
  };
  return download;
}
```

**使い方**

[Fetch API](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API)でファイルデータを取得し、ResponseのblobメソッドでBlobオブジェクトを得ます。
そのBlobオブジェクトとファイル名を`useDownloader`から返却された関数に渡してダウンロードを行います。

```tsx
const download = useDownloader();
const blob = (await fetch('/api/download')).blob();
const filename = 'file-name.csv';
download(blob, filename);
```

# まとめ

以上、React Hooksのご紹介と、独自で作成したフックの紹介でした。
使えそうなフックがありましたら、是非使ってみてください。