この記事は、以下の記事を参考にさせて頂きました。
『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)』
import React, { FC } from "react";
const NumBtn: FC<{ n: number }> = ({ n }) => <button>{n}</button>;
export default NumBtn;
『+, -, ×, ÷の計算ボタン(OperatorBtn.tsx)』
import React, { FC } from "react";
const OperatorBtn: FC<{ o: string }> = ({ o }) => <button>{o}</button>;
export default OperatorBtn;
『計算結果を表示する(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です。
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;
次に、スタイルを適用します。
# 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
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の書き方はこちらを参考にさせて頂きました。
コメントにあるeslint-disable-lineは、eslintで_が未使用と警告がでるので、この行だけeslintを無効にしています。
Context
Reducerをもとに状態を管理する部分です。ReduxだとStoreの部分に当たります。
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;
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を渡します。
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を取得して呼び出すように変更します。
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との違いは、プロパティで呼び出し側からアクションが指定される所です。
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;
以上でとりあえず完成です。