Reactで電卓を作る。【TypeScript版(Redux未使用)】

この記事は、以下の記事を参考にさせて頂きました。

[『React』+『Redux』でiPhoneに入ってるような電卓を作ってみよう！ - Qiita](https://qiita.com/micropig3402/items/d1221652cdc6a4efccbe)

この記事では、以下の方針で作ってみます。

- TypeScriptを使う
- Reactのみで実装
- Fluxパターンは使用する
- Reduxの代わりにHooks(useReducer)とContextを使う

完成後のソースコードは[こちら](https://github.com/otanu/calculator-ts)。

## 前提

- create-react-app: 3.0.1
- react: 16.8.6

## create-react-app

まず土台を作ります。引数に`--typescript`を付けることで、TypeScriptの形式で出力されます。

```sh
$ create-react-app calculator-ts --typescript
$ cd caluculator-ts
```

## 見た目の作成

まず、Componentを作成します。
コンポーネントは、`FC(functional component)`の型です。

『0~9の数字ボタン(NumBtn.tsx)』

```tsx:src/components/NumBtn.tsx
import React, { FC } from "react";

const NumBtn: FC<{ n: number }> = ({ n }) => <button>{n}</button>;

export default NumBtn;
```

『+, -, ×, ÷の計算ボタン(OperatorBtn.tsx)』

```tsx:src/components/OperatorBtn.tsx
import React, { FC } from "react";

const OperatorBtn: FC<{ o: string }> = ({ o }) => <button>{o}</button>;

export default OperatorBtn;
```

『計算結果を表示する(Result.tsx)』

```tsx:src/components/Result.tsx
import React, { FC } from "react";

const Result: FC<{ result: number | string }> = ({ result }) => (
  <div className="resultValue">{result}</div>
);

export default Result;
```

結果の型は仮組み時・エラー時の場合は文字列、通常は数字なので、とりあえず`number | string`の型にしてあります。


定義したコンポーネントを使ってアプリケーションを組み立てます。
アプリケーションもFCです。

```tsx:src/App.tsx
import React from "react";

import "./App.css";
import NumBtn from "./components/NumBtn";
import OperatorBtn from "./components/OperatorBtn";
import Result from "./components/Result";

const App: React.FC = () => {
  return (
    <>
      <div className="result">
        <Result result={"計算結果"} />
      </div>
      <div className="wrapper">
        <div className="number">
          <div className="numUpper">
            <NumBtn n={7} />
            <NumBtn n={8} />
            <NumBtn n={9} />
          </div>
          <div className="numMiddle">
            <NumBtn n={4} />
            <NumBtn n={5} />
            <NumBtn n={6} />
          </div>
          <div className="numLower">
            <NumBtn n={1} />
            <NumBtn n={2} />
            <NumBtn n={3} />
          </div>
          <div className="zero">
            <NumBtn n={0} />
            <span className="allClear">
              <OperatorBtn o={"AC"} />
            </span>
            <span className="equal">
              <OperatorBtn o={"="} />
            </span>
          </div>
        </div>
        <div className="operator">
          <OperatorBtn o={"÷"} />
          <OperatorBtn o={"×"} />
          <OperatorBtn o={"-"} />
          <OperatorBtn o={"+"} />
        </div>
      </div>
    </>
  );
};

export default App;
```

次に、スタイルを適用します。

```css:src/App.css
#root {
  width:200px;
  margin: 40px auto;
}

.result {
  height: 40px;
  background: black;
}

.resultValue {
  text-align: end;
  height: 100%;
  font-size: 30px;
  color: white;
  padding-right: 15px; 
}

.wrapper {
  display: flex;
  justify-content: center;
  background: black;
}

button {
  border-radius: 50%;
  width: 40px;
  height: 40px;
  margin: 5px;
  background: #555555;
  border: none;
  color: white;
  font-size: 25px;
  padding: 0;
}

button:hover {
  background: #999999;
}

button:focus {
  outline: none;
}

.operator button {
  background: orange;
}

.operator button:hover{
  background: #FFC7AF;
}

.operator button:focus {
  background: white;
  color: orange;
}

.operator {
  display: flex;
  flex-direction: column;
}
```

## 中身の実装

### Action,Reducer

```ts:src/reducers/index.ts
const INPUT_NUMBER = "INPUT_NUMBER";
const PLUS = "PLUS";
const MINUS = "MINUS";
const MULTIPLY = "MULTIPKY";
const DIVIDE = "DVIDE";
const EQUAL = "EQUAL";
const CLEAR = "CLEAR";

export const onNumClick = (number: number) => ({
  types: INPUT_NUMBER as typeof INPUT_NUMBER,
  number
});

export const onPlusClick = () => ({
  types: PLUS as typeof PLUS
});

export const onMinusClick = () => ({
  types: MINUS as typeof MINUS
});

export const onMultiplyClick = () => ({
  types: MULTIPLY as typeof MULTIPLY
});

export const onDivideClick = () => ({
  types: DIVIDE as typeof DIVIDE
});

export const onEqualClick = () => ({
  types: EQUAL as typeof EQUAL
});

export const onClearClick = () => ({
  types: CLEAR as typeof CLEAR
});

export type Actions =
  | ReturnType<typeof onNumClick>
  | ReturnType<typeof onPlusClick>
  | ReturnType<typeof onMinusClick>
  | ReturnType<typeof onMultiplyClick>
  | ReturnType<typeof onDivideClick>
  | ReturnType<typeof onEqualClick>
  | ReturnType<typeof onClearClick>;

export type AppState = {
  inputValue: number;
  operator: "" | "+" | "-" | "*" | "/";
  resultValue: number;
  calculate: boolean;
  showingResult: boolean;
};

export const initialAppState: AppState = {
  inputValue: 0,
  operator: "",
  resultValue: 0,
  calculate: false,
  showingResult: false
};

export const reducer = (state: AppState, action: Actions): AppState => {
  switch (action.types) {
    case INPUT_NUMBER:
      return {
        ...state,
        inputValue: state.inputValue * 10 + action.number,
        showingResult: false
      };

    case PLUS:
      if (state.calculate === true) {
        return {
          ...state,
          inputValue: 0,
          operator: "+",
          resultValue: state.resultValue + state.inputValue,
          showingResult: true
        };
      } else {
        return {
          ...state,
          inputValue: 0,
          operator: "+",
          calculate: true,
          resultValue: state.inputValue,
          showingResult: true
        };
      }

    case MINUS:
      if (state.calculate === true) {
        return {
          ...state,
          inputValue: 0,
          operator: "-",
          resultValue: state.resultValue - state.inputValue,
          showingResult: true
        };
      } else {
        return {
          ...state,
          inputValue: 0,
          operator: "-",
          calculate: true,
          resultValue: state.inputValue,
          showingResult: true
        };
      }

    case MULTIPLY:
      if (state.calculate === true) {
        return {
          ...state,
          inputValue: 0,
          operator: "*",
          resultValue: state.resultValue * state.inputValue,
          showingResult: true
        };
      } else {
        return {
          ...state,
          inputValue: 0,
          operator: "*",
          calculate: true,
          resultValue: state.inputValue,
          showingResult: true
        };
      }

    case DIVIDE:
      if (state.calculate === true) {
        return {
          ...state,
          inputValue: 0,
          operator: "/",
          resultValue: state.resultValue / state.inputValue,
          showingResult: true
        };
      } else {
        return {
          ...state,
          inputValue: 0,
          operator: "/",
          calculate: true,
          resultValue: state.inputValue,
          showingResult: true
        };
      }

    case CLEAR:
      return {
        inputValue: 0,
        operator: "",
        calculate: false,
        resultValue: 0,
        showingResult: false
      };
    case EQUAL:
      switch (state.operator) {
        case "+":
          return {
            inputValue: state.resultValue + state.inputValue,
            operator: "",
            calculate: false,
            resultValue: state.resultValue + state.inputValue,
            showingResult: true
          };
        case "-":
          return {
            inputValue: state.resultValue - state.inputValue,
            operator: "",
            calculate: false,
            resultValue: state.resultValue - state.inputValue,
            showingResult: true
          };
        case "*":
          return {
            inputValue: state.resultValue * state.inputValue,
            operator: "",
            calculate: false,
            resultValue: state.resultValue * state.inputValue,
            showingResult: true
          };
        case "/":
          return {
            inputValue: state.resultValue / state.inputValue,
            operator: "",
            calculate: false,
            resultValue: state.resultValue / state.inputValue,
            showingResult: true
          };
        default:
          return state;
      }

    default:
      const _: never = action; // eslint-disable-line
  }
  return state;
};
```

Reducerの書き方はこちらを参考にさせて頂きました。
- [TypeScriptで Redux の Reducer部分を型安全かつスッキリ書く - Qiita](https://qiita.com/taqm/items/4d6c5c12296fd1300086)

コメントにある`eslint-disable-line`は、eslintで`_`が未使用と警告がでるので、この行だけeslintを無効にしています。

### Context

Reducerをもとに状態を管理する部分です。ReduxだとStoreの部分に当たります。


```tsx:src/components/Context.tsx
import { createContext } from "react";
import { AppState, Actions, initialAppState } from "../reducers";

type ContextState = {
  state: AppState;
  dispatch(action: Actions): void;
};

const Context = createContext<ContextState>({
  state: initialAppState,
  dispatch(_) {
    console.warn("Context.Provider外からの呼び出し");
  }
});

export default Context;
```

```tsx:src/index.tsx
import React, { useReducer } from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

import Context from "./components/Context";
import { initialAppState, reducer } from "./reducers";

const Index = () => {
  const [state, dispatch] = useReducer(reducer, initialAppState);

  return (
    <Context.Provider value={{ state, dispatch }}>
      <App />
    </Context.Provider>
  );
};

ReactDOM.render(<Index />, document.getElementById("root"));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
```

Stateの管理とReducerの呼び出しは、Hooksの`useReducer`を使います。
`useReducer`が返した`dispatch`関数にActionを渡して呼ぶことで、Reducerが実行され、`state`が更新されます。
これを`Context.Providor`に渡すことで、`Context`を参照している子コンポーネントが、最新の`state`で再描画されます。

`dispatch`の使い方は、次の`Contextを使う`で説明します。

なお、初期値を指定する`initialAppState`が`createContext`と`useReducer`の二箇所で使用されているのでややこしいですが、使用されるのは`useReducer`の方です。
仮に、Context外(`Context.Providor`の子ではないコンポーネント)から参照された場合に気づけるように、`dispatch`には警告を出す関数を登録しています。

### Contextを使う

Reduxの`connect`の部分にあたります。
Hooksの`useContext`でContextから`state`と`dispatch`を取り出して使用します。

アプリケーションの方では`state`を取り出して、`Result`に渡します。
また、`OperatorBtn`にボタン毎に合わせたActionを渡します。

```tsx:src/App.tsx
import React, { useContext } from "react";

import "./App.css";
import NumBtn from "./components/NumBtn";
import OperatorBtn from "./components/OperatorBtn";
import Result from "./components/Result";
import Context from "./components/Context";
import {
  onClearClick,
  onEqualClick,
  onDivideClick,
  onMultiplyClick,
  onPlusClick,
  onMinusClick
} from "./reducers";

const App: React.FC = () => {
  const { state } = useContext(Context);

  return (
    <>
      <div className="result">
        <Result
          result={state.showingResult ? state.resultValue : state.inputValue}
        />
      </div>
      <div className="wrapper">
        <div className="number">
          <div className="numUpper">
            <NumBtn n={7} />
            <NumBtn n={8} />
            <NumBtn n={9} />
          </div>
          <div className="numMiddle">
            <NumBtn n={4} />
            <NumBtn n={5} />
            <NumBtn n={6} />
          </div>
          <div className="numLower">
            <NumBtn n={1} />
            <NumBtn n={2} />
            <NumBtn n={3} />
          </div>
          <div className="zero">
            <NumBtn n={0} />
            <span className="allClear">
              <OperatorBtn o={"AC"} action={onClearClick()} />
            </span>
            <span className="equal">
              <OperatorBtn o={"="} action={onEqualClick()} />
            </span>
          </div>
        </div>
        <div className="operator">
          <OperatorBtn o={"÷"} action={onDivideClick()} />
          <OperatorBtn o={"×"} action={onMultiplyClick()} />
          <OperatorBtn o={"-"} action={onMinusClick()} />
          <OperatorBtn o={"+"} action={onPlusClick()} />
        </div>
      </div>
    </>
  );
};

export default App;
```

『0~9の数字ボタン(NumBtn.tsx)』

Contextを通して`useReducer`の`dispatch`を取得して呼び出すように変更します。

```tsx:src/components/NumBtn.tsx
import React, { FC, useContext } from "react";
import Context from "./Context";
import { onNumClick } from "../reducers";

const NumBtn: FC<{ n: number }> = ({ n }) => {
  const { dispatch } = useContext(Context);

  return <button onClick={() => dispatch(onNumClick(n))}>{n}</button>;
};

export default NumBtn;
```

『+, -, ×, ÷の計算ボタン(OperatorBtn.tsx)』

`NumBtn`との違いは、プロパティで呼び出し側からアクションが指定される所です。

```tsx:src/components/OperatorBtn.tsx
import React, { FC, useContext } from "react";
import Context from "./Context";
import { Actions } from "../reducers";

const OperatorBtn: FC<{ o: string; action: Actions }> = ({ o, action }) => {
  const { dispatch } = useContext(Context);

  return <button onClick={() => dispatch(action)}>{o}</button>;
};

export default OperatorBtn;
```

以上でとりあえず完成です。


## リファレンス

- [『React』+『Redux』でiPhoneに入ってるような電卓を作ってみよう！ - Qiita](https://qiita.com/micropig3402/items/d1221652cdc6a4efccbe)
- [TypeScriptで Redux の Reducer部分を型安全かつスッキリ書く - Qiita](https://qiita.com/taqm/items/4d6c5c12296fd1300086)