LoginSignup
6
10

More than 3 years have passed since last update.

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

Posted at

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

『React』+『Redux』でiPhoneに入ってるような電卓を作ってみよう! - Qiita

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

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

完成後のソースコードはこちら

前提

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

create-react-app

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

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

見た目の作成

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

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

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

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

export default NumBtn;

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

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

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

export default OperatorBtn;

『計算結果を表示する(Result.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です。

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;

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

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

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

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

Context

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

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;
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を使うで説明します。

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

Contextを使う

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

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

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を通してuseReducerdispatchを取得して呼び出すように変更します。

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との違いは、プロパティで呼び出し側からアクションが指定される所です。

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;

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

リファレンス

6
10
1

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
6
10