この記事は、以下の記事を参考にさせて頂きました。
『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;
以上でとりあえず完成です。