LoginSignup
3
3

More than 3 years have passed since last update.

電卓アプリをredux&typescriptで作る

Posted at

初めに

プライベートで全然コード書かない私は、せっかく前に少し勉強したreduxを完全に忘れてしまったのだった。そして今度仕事でreact-reduxを使う可能性が出てきたので慌てて復習しようと思ったところ、初心者向け記事があったので参考にしてみた。
電卓アプリで学ぶReact/Redux入門(実装編)

上記は、Reactから始まってreduxとつないでというところまで丁寧に書いてあるのでためになったのですが、私はtypescriptで書きたい人間なので、記事を参考にtypescriptで書き直してみました。
1からのステップを学びたい方は上記の記事を参考にしてみてください。私と同じく、typescriptで書きたいけど型定義などで苦戦して動かないという方はこの記事を参考にしていただければと思います。

私はreduxのプロでも何でもないため、より良い書き方などありましたらコメントいただけると幸いです。
いいからコードよこせという方は下記
https://github.com/teshimafu/redux_calclator_sample

いいから動き見せろって人は下記
https://teshimafu.github.io/redux_calculator_sample/

仕様

・数字どうしの四則演算ができる。ただし、毎回計算結果を出すため、乗除優先ではない。
・数字、演算子、数字、演算子。。。と計算可能。
 例:1+2+3-4*5=10
・=を連続して押しても再計算しない。
・=後の数字入力では、数字はクリアされた上で数字入力される。(これくらい直すかな)
・小数入力不可。
・計算結果に小数は可。
・ボタンの配置がガタついているのは仕様。何と言われても仕様。

雑なつくりで特に更新するつもりもないので、バグってても直さないかも。あくまでもredux初心者が動くとこまで使ったということで。

なんでTypescript?

時々こう言われますが、別にjavascript否定するつもりは毛頭ないので、javascriptで書けばいいじゃんと思う人はブラウザバック推奨。ただ、typescriptは誰でも見やすいという利点がある(と思っている)ので、チームでの開発とか初心者がフロントの勉強を始めようと考えている場合にはお勧めします。javascriptのほうが記述量は少ないですし型定義なども不要ではありますが、変数に何が入るのかを第三者が見つけにくかったり、実行するまで問題に気づけないことも多いという欠点があります。上級者は経験で問題を回避できるため欠点が欠点になりません。だからjavascript好きな人も一定数いるのだと思います。(MSが嫌いとか言わない)

ひな型をつくろう

公式にtypescriptがサポートされているので、言われた通りにしましょう
https://create-react-app.dev/docs/adding-typescript/

npx create-react-app my-app --template typescript

npxはnpmの最新バージョンで実行するのと同じ意味。無ければnpmコマンドで入れてね。
数分後にmy-appというフォルダが作成されるので、その中のsrc以下の構造を下記のように作り直します。ディレクトリ構造は好みなどあると思いますので、あくまで一例としてご覧ください。cssやアイコンは使わないため省略。

├── App.tsx
├── index.tsx
├── react-app-env.d.ts
├── serviceWorker.ts
├── setupTests.ts
├── store.ts
├── components
│   ├── CommonBtn.tsx
│   └── Result.tsx
├── containers
│   └── CalculatorContainer.tsx
├── modules
│   └── CalculatorContainer.ts
└── services
│   └── CalculatorService.ts

参照元の記事から変更した構成は次の通りです。
・action,reducerをmodulesにまとめた。
・四則演算はcontainersから切り離したかったのでservicesに取り出した。
・componentsを汎用化。

詳しくは次の章で見ていきます。

components

主にView担当。ボタンや表示部の構造が入っています。

CommonBtn.tsx

CommonBtn.tsx
import React from "react";

interface ButtonArgument {
  character: string | number;
  onClick: () => void;
}

const CommonBtn = ({ character, onClick }: ButtonArgument) => <button onClick={onClick}>{character}</button>;

export default CommonBtn;

電卓のボタンを構成するcomponentです。
引数には、stringまたはnumber型のcharacterと、関数型のonClickを受け取ります。
characterはボタンに表示される文字や数字になります。
onClickはMouseEvent型の関数で、ボタンを押された時の挙動を受け取ります。
CommonBtnの内部では関数の情報は持たないため、呼び出し元で挙動を決定できます。こうすることでボタンの汎用性を持たせることができます。

Result.tsx

Result.tsx
import React from "react";

interface ResultArgument {
  title: string;
  result: number;
}

const Result = ({ title, result }: ResultArgument) => (
  <div>
    {title}: <span>{result}</span>
  </div>
);

export default Result;


計算結果を表示するためのcomponentです。
何を表示しているかを伝えるためのtitleと、値を表示するためのresultを受け取って表示するだけになります。

containers

今回は1個しかないけど複数形のフォルダ。以降も同様
2019年中ごろのhooks登場で、reduxとreactのつなぎが少し楽になりました。楽になったバージョンで書きます。

CalculatorContainer.tsx

CalculatorContainer.tsx
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import Result from "../components/Result";
import { GetAllActions } from "../modules/CalculatorContainer";
import { AllState } from "src/store";
import { OPT } from "src/services/CalculatorService";
import CommonBtn from "src/components/CommonBtn";

export const CalculatorContainer = () => {
  const dispatch = useDispatch();
  const calculator = useSelector((state: AllState) => state.calculator);
  const onNumClick = (number: number) => {
    dispatch(GetAllActions.onNumClick(number));
  };
  const onOperationClick = (opt: OPT) => {
    dispatch(GetAllActions.onOperationClick(opt));
  };
  const onEqualClick = () => {
    dispatch(GetAllActions.onEqualClick());
  };
  const onResetClick = () => {
    dispatch(GetAllActions.onResetClick());
  };
  return (
    <div>
      <div>
        <CommonBtn character={1} onClick={() => onNumClick(1)} />
        <CommonBtn character={2} onClick={() => onNumClick(2)} />
        <CommonBtn character={3} onClick={() => onNumClick(3)} />
        <CommonBtn character={"/"} onClick={() => onOperationClick("DIV")} />
      </div>
      <div>
        <CommonBtn character={4} onClick={() => onNumClick(4)} />
        <CommonBtn character={5} onClick={() => onNumClick(5)} />
        <CommonBtn character={6} onClick={() => onNumClick(6)} />
        <CommonBtn character={"*"} onClick={() => onOperationClick("MUL")} />
      </div>
      <div>
        <CommonBtn character={7} onClick={() => onNumClick(7)} />
        <CommonBtn character={8} onClick={() => onNumClick(8)} />
        <CommonBtn character={9} onClick={() => onNumClick(9)} />
        <CommonBtn character={"+"} onClick={() => onOperationClick("ADD")} />
      </div>
      <div>
        <CommonBtn character={"c"} onClick={onResetClick} />
        <CommonBtn character={0} onClick={() => onNumClick(0)} />
        <CommonBtn character={"="} onClick={onEqualClick} />
        <CommonBtn character={"-"} onClick={() => onOperationClick("SUB")} />
      </div>
      <Result title={"Temporary"} result={calculator.temporaryValue} />
      <Result title={"Result"} result={calculator.showingResult ? calculator.resultValue : calculator.inputValue} />
    </div>
  );
};

上から順に

  const dispatch = useDispatch();
  const calculator = useSelector((state: AllState) => state.calculator);

以前は、mapStateToPropsとかDispatchがどうこうというおまじないをcontainerの下のほうに書いて、connectするという作業を行っていました。それの代わりとなります。詳しい使い方は公式ドキュメントをみると良いかも。https://react-redux.js.org/api/hooks
AllStateは、Redux管理しているすべてのステータスを保持する構造体で、store.tsで定義します。

calculatorには、reduxから取ってきた計算に使うstatusが入っています。

  const onNumClick = (number: number) => {
    dispatch(GetAllActions.onNumClick(number));
  };
  const onOperationClick = (opt: OPT) => {
    dispatch(GetAllActions.onOperationClick(opt));
  };
  const onEqualClick = () => {
    dispatch(GetAllActions.onEqualClick());
  };
  const onResetClick = () => {
    dispatch(GetAllActions.onResetClick());
  };

UI上のボタンクリック時の挙動をReduxのActionとつないでいます。つないだ先での挙動はmodulesで説明します。ここでは、ViewとLogicを接続しているとだけ理解すればよいです。

  return (
    <div>
      <div>
        <CommonBtn character={1} onClick={() => onNumClick(1)} />
        <CommonBtn character={2} onClick={() => onNumClick(2)} />
        <CommonBtn character={3} onClick={() => onNumClick(3)} />
        <CommonBtn character={"/"} onClick={() => onOperationClick("DIV")} />
      </div>
      <div>
        <CommonBtn character={4} onClick={() => onNumClick(4)} />
        <CommonBtn character={5} onClick={() => onNumClick(5)} />
        <CommonBtn character={6} onClick={() => onNumClick(6)} />
        <CommonBtn character={"*"} onClick={() => onOperationClick("MUL")} />
      </div>
      <div>
        <CommonBtn character={7} onClick={() => onNumClick(7)} />
        <CommonBtn character={8} onClick={() => onNumClick(8)} />
        <CommonBtn character={9} onClick={() => onNumClick(9)} />
        <CommonBtn character={"+"} onClick={() => onOperationClick("ADD")} />
      </div>
      <div>
        <CommonBtn character={"c"} onClick={onResetClick} />
        <CommonBtn character={0} onClick={() => onNumClick(0)} />
        <CommonBtn character={"="} onClick={onEqualClick} />
        <CommonBtn character={"-"} onClick={() => onOperationClick("SUB")} />
      </div>

これは、実際のUIを見たほうが早いと思うので、説明の前に見せます。
image.png

CommonBtn1個につき1つのボタンが割り当てられています。
character={0~9}が渡されているのは、数字ボタンです。同時に、onNumClickという関数が渡されています。onNumClickは数字ボタンが押された時にイベント発火する関数で先に述べたreduxのactionに押した数字を引数として渡しています。

character={"/"} character={"*"} character={"+"} character={"-"}を渡しているのは、四則演算の記号です。onOperationClickでつないだreduxのactionに紐づいています。引数の文字列については、modulesで登場します。

character={"c"} character={"="}はそれぞれつないでいるActionが異なりますが、記述方法はほぼ同じです


      <Result title={"Temporary"} result={calculator.temporaryValue} />
      <Result title={"Result"} result={calculator.showingResult ? calculator.resultValue : calculator.inputValue} />

ここは、ResultComponentにtitleと値を渡しています。temporaryとResultを表示します。
calculatorは、本節の序盤で話したように、reduxから取ってきたstateが入っているので、temporaryValueや、inputValueというstatusにアクセスできます。reduxなので、このContainerで管理するステータスは存在せず、あくまでもstateはreduxで管理して、使いたいときに取得するという書き方になります。

modules

CalculatorContainer.ts

CalculatorContainer.ts
import { OPT, Calculator } from "src/services/CalculatorService";

const INPUT_NUMBER = "INPUT_NUMBER";
const OPERATION = "OPERATION";
const EQUAL = "EQUAL";
const RESET = "RESET";

const onNumClick = (number: number) => ({
  type: INPUT_NUMBER,
  number
});

const onOperationClick = (opt: OPT) => ({
  type: OPERATION,
  opt
});

const onEqualClick = () => ({
  type: EQUAL
});

const onResetClick = () => ({
  type: RESET
});

type ClickActions =
  | ReturnType<typeof onNumClick>
  | ReturnType<typeof onOperationClick>
  | ReturnType<typeof onEqualClick>
  | ReturnType<typeof onResetClick>;

interface CalcState {
  inputValue: number;
  resultValue: number;
  temporaryValue: number;
  lastOperation?: OPT;
  showingResult: boolean;
}

export const GetAllActions = {
  onNumClick: onNumClick,
  onOperationClick: onOperationClick,
  onEqualClick: onEqualClick,
  onResetClick: onResetClick
};

const initialAppState: CalcState = {
  inputValue: 0,
  temporaryValue: 0,
  resultValue: 0,
  showingResult: false
};

const calculator = (state = initialAppState, action: ClickActions) => {
  switch (action.type) {
    case INPUT_NUMBER:
      const numAction = action as ReturnType<typeof onNumClick>;
      return {
        ...state,
        inputValue: state.inputValue * 10 + numAction.number,
        showingResult: false
      };
    case OPERATION:
      const opAction = action as ReturnType<typeof onOperationClick>;
      const temp = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.showingResult
        ? state.resultValue
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: temp,
        resultValue: 0,
        lastOperation: opAction.opt
      };
    case EQUAL:
      if (state.showingResult) {
        return state;
      }
      const result = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: 0,
        resultValue: result,
        lastOperation: undefined,
        showingResult: true
      };
    case RESET:
      return initialAppState;
    default:
      return state;
  }
};

export default calculator;

また上から順に説明します

Action


const INPUT_NUMBER = "INPUT_NUMBER";
const OPERATION = "OPERATION";
const EQUAL = "EQUAL";
const RESET = "RESET";

const onNumClick = (number: number) => ({
  type: INPUT_NUMBER,
  number
});

const onOperationClick = (opt: OPT) => ({
  type: OPERATION,
  opt
});

const onEqualClick = () => ({
  type: EQUAL
});

const onResetClick = () => ({
  type: RESET
});

action定義です。最上部で、actionの名称を定義しています。そのあとに、各関数のtypeと引数の定義を行っています。
例えば、onNumClickINPUT_NUMBERという名称のtypeのactionで、numberという引数を受け取ります。

type ClickActions =
  | ReturnType<typeof onNumClick>
  | ReturnType<typeof onOperationClick>
  | ReturnType<typeof onEqualClick>
  | ReturnType<typeof onResetClick>;

interface CalcState {
  inputValue: number;
  resultValue: number;
  temporaryValue: number;
  lastOperation?: OPT;
  showingResult: boolean;
}

export const GetAllActions = {
  onNumClick: onNumClick,
  onOperationClick: onOperationClick,
  onEqualClick: onEqualClick,
  onResetClick: onResetClick
};

ClickActionsは、定義したActionの戻り値の型定義。
CalcStateは、reduxで管理したい型定義。
GetAllActionsは、上部で定義した関数を取りまとめたもの。(名前微妙かも)

Reducer


const initialAppState: CalcState = {
  inputValue: 0,
  temporaryValue: 0,
  resultValue: 0,
  showingResult: false
};

stateの初期値です。


const calculator = (state = initialAppState, action: ClickActions) => {

stateには、reduxで現在保持しているstateが入る。初期値はinitialAppState。第二引数がClickActionsで定義されたactionでtypeによって後述されるactionが異なる。


  switch (action.type) {
    case INPUT_NUMBER:
      const numAction = action as ReturnType<typeof onNumClick>;
      return {
        ...state,
        inputValue: state.inputValue * 10 + numAction.number,
        showingResult: false
      };
    case OPERATION:
      const opAction = action as ReturnType<typeof onOperationClick>;
      const temp = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.showingResult
        ? state.resultValue
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: temp,
        resultValue: 0,
        lastOperation: opAction.opt
      };
    case EQUAL:
      if (state.showingResult) {
        return state;
      }
      const result = state.lastOperation
        ? Calculator(state.lastOperation, state.temporaryValue, state.inputValue)
        : state.inputValue;
      return {
        ...state,
        inputValue: 0,
        temporaryValue: 0,
        resultValue: result,
        lastOperation: undefined,
        showingResult: true
      };
    case RESET:
      return initialAppState;
    default:
      return state;
  }
};

switch~caseでactionごとの挙動を定義。
case INPUT_NUMBERではinputValueに値を入れていきます。10*で加算することで、数字入力を表現しています。
const numAction = action as ReturnType;と書いているのは、onNumClickのactionだけが持つnumberという値を使いたいからです。ここはもう少しきれいに書けそう。ReturnType使っているので型推論だけでうまくいかなくなっている。(typescriptの話掘り下げるときりがないので気になる方はtype guardとかで調べてみてください)

case OPERATIONでは、Optを入れてstateを更新します。Optには四則演算のどれかが入るOPTという型が定義されています。Containerで登場したDIVや、ADDといった引数はここで利用されstateに保存されます。
temporaryValueに入れる値は、Calculatorという関数の計算結果または、現在入力された数字が入ります。ReturnTypeの下りはnumActionと同じ理由。
なんで条件が多少ごちゃごちゃしているかというと、
1+2=3とした後に結果を計算したいとき対応や、1+2+3-4*...のように計算を連ねるときの対応のために三項演算子を使用している。本筋ではないため深堀はしない。

case EQUALでは、計算結果をresultValueに入れます。計算自体はCalculatorで行います。今回は面倒くさかったので、=を連打されても再計算はしません。演算子直後のイコールも対応しません。イコール後に数字入れると結果がリセットされます。バグではなく仕様です(言い訳)。

case RESETは初期化です。initialStateをstateに設定します。

services

この分け方は私のやり方であり、ほかの書き方もたくさんあるのであくまでも一例です。
servicesには、react,reduxに依存しないロジックを書きます。急にreactやめてangularにするで~とか言われても困るからね。

CalculatorService.ts

CalculatorService.ts
export type OPT = "DIV" | "MUL" | "ADD" | "SUB";

export const Calculator = (opt: OPT, firstNumber: number, secondNumber: number) => {
  switch (opt) {
    case "DIV":
      return firstNumber / secondNumber;
    case "MUL":
      return firstNumber * secondNumber;
    case "ADD":
      return firstNumber + secondNumber;
    case "SUB":
      return firstNumber - secondNumber;
    default:
      return 0;
  }
};

計算はここにまとめています。今までもちらほら登場していたOPTはここで定義しています。規模が大きいなら、modelsとかでtypeの定義は別ファイルにするのが良いでしょう。今回は面倒くさくて規模が小さいため同じファイルです。OPTは四則演算4つを表すstring文字だけ入るtypeです。
Calculatorは、演算子と2つの数字を受け取って演算子ごとの計算結果を返します。

その他のファイル

store.ts

redux必須のやつ。

store.ts
import { combineReducers, createStore } from "redux";
import calculator from "./modules/CalculatorContainer";

// 全てのReducerが集約される。Reducerが増えたらここに追加する
export const rootReducer = combineReducers({
  calculator
});

// 全てのStatusが集約される。書き換え不要
export type AllState = ReturnType<typeof rootReducer>;

// Reactとreduxをつなぐためのstoreを作成。書き換え不要
const store = createStore(rootReducer);

export default store;

コメントに書いた通りです。
AllStateはredux管理の全てのstateのタイプを持ち、Containerごとに必要なStateを呼び出します(ここ言い切ってるけど合ってるよね?)

App.tsx

Container名だけ今回のものにしただけ

App.tsx
import React from "react";
import "./App.css";
import { CalculatorContainer } from "./containers/CalculatorContainer";

function App() {
  return (
    <div className="App">
      <CalculatorContainer />
    </div>
  );
}

export default App;

index.tsx

storeの紐づけをした。ググればわかると思う。

index.tsx
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import * as serviceWorker from "./serviceWorker";
import { Provider } from "react-redux";
import store from "./store";
import App from "./App";

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  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();

今回触れなかったファイルはデフォルトのままなので説明省略。

まとめ

電卓アプリで学ぶReact/Redux入門(実装編)の説明+本記事で、react-reduxかつtypescriptで初心者でも電卓が作れる!!!ことを願う。

3
3
0

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
3
3