はじめに
去年からフロントは React、バックエンドは Django(DRF)で何か作れないのかということをやっているのですがTypeScript から逃げれないという圧も感じたものの、割とこのあたりはできる人は当たり前にやっていくので情報も少なくどうしたものかと悩んでいたところ、
[基礎編]React Hooks + Django REST Framework API でフルスタック Web 開発
こういう講座を見つけて、同じ講師の方の派生の講座もうまいこと React 周りのフロント技術の良い講座があったり、Django との連携・テストまで解説している講座があったのでこれはいい機会だと思って、今まで独学でやってきたことを整理・リファクタリングする意味も込めてちょっと体系的に勉強し始めたのでその覚書です。
実は開発を体系的に収めたいというのには、AWS SAA を取ったのでその知識を腐らせたくないということと派生の DVA や DevOps の資格を取るために、そこで出題されるサービスをハンズオンでやりたいというのもあるのでそのためには……という意図もあったりします。
以下、上記講座の内容を自分で TS 化していて詰まったところとか、適切とは言えない解決の仕方をしているところを少し取り上げていきます。
作業していたリポジトリは以下の通り。
react-django-beginners
react-django-beginners-DRFAPI
useState の場合
まず最初に詰まったのはこのパターン。
以下のパターンくらいまでは問題なかったのですが
// いわゆるプリミティブ型と言われるタイプ。初期値を設定すれば型推論してくれる
const [text, setText] = useState("");
const [number, setNumber] = useState(1);
const [textlist, setTextList] = useState(["sample", "text"]);
const [numberlist, setNumberList] = useState([1, 2, 3, 4]);
const [user, setUser] = useState({
name: "user",
password: "testuser",
version: "1.5",
});
// プリミティブでもnullを含む場合は定義をしておくほうがいい
const [count, setCount] = useState<number | null>(null);
// 初期値を空のリストにしたい場合は定義をしておく
const [textlist, setTextList] = useState<string[]>([]);
const [numberlist, setNumberList] = useState<number[]>([]);
const [multilist, setMultiList] = useState<string | number[]>([]);
下記のようなパターンを理解するのにちょっと悩みました。
なお、以後の項目ではさらに型の指定に悩む部分が出てきますがそれはその時に
// 例えばAPIから以下のようなデータを受け取り、それをuseStateで扱いたいとする。
interface Task {
id: number;
title: string;
}
// その場合まずAPIからの返り値はまず、一括取得等Objectのリストになる場合はGenericを使ってinterface[]で定義。ここまではいい。
const [tasks, setTasks] = useState<Task[]>([]);
// 次に単一データの場合は初期値が空にしたいがこれだと当然NG(interface側でプロパティに?をつけていたら問題ない)
const [selectedtask, setSelectedTask] = useState<Task>({});
// これならOKだが、無闇に型アサーションを使うのは良くない
const [selectedtask, setSelectedTask] = useState<Task>({} as Task);
// なのでanyを使うか、適切な初期値を設定するのがベターなのだろうか?
// ただし,プロパティの初期値を設定する場合はid:""でもid:nullでもstring型として扱われてエラーが出るのでanyにするしかなさそう
const [selectedtask, setSelectedTask] = useState<any>({});
// 以下の場合もanyを使ったが………本当にいいのだろうか?
const [editedTask, setEditedTask] = useState<any>({
id: "",
title: "",
});
// inputタグからの値は文字列扱いになるのでこうするしかない。またはNumber型に変換してからsetStateするか。
const [id, setId] = useState<string | number>(1);
useContext+useReducer の場合
ポイントだと思った点は
- ACTIONTYPE にも型を定義する
- Reducer の state の初期値はオブジェクトにする
- Context と Reducer 部分は 1 ファイルにまとめた方がいい
- createContext の型定義の扱い
createContext の型定義の扱いは
こういう知見 もあるので要研究な気がします。
App.tsx
import React from "react";
import "./App.css";
import logo from "./logo.svg";
import AppContext from "./contexts/AppContext";
import { useReducer } from "react";
import CompB from "./components/CompB";
const initialState = { count: 0 };
// actionにあてるためにACTIONTYPEを型にしておく。payload(actionから任意の値などを引数としてもらってそれを使って返す場合のプロパティ)が必要な場合はそちらも。
type ACTIONTYPE =
| { type: "add_1"; payload?: number }
| { type: "multiple_3"; payload?: number }
| { type: "reset"; payload?: typeof initialState };
const reducer = (currentState: typeof initialState, action: ACTIONTYPE) => {
// typeプロパティを引数にする
switch (action.type) {
case "add_1":
return { count: currentState.count + 1 };
case "multiple_3":
return { count: currentState.count * 3 };
case "reset":
return initialState;
default:
return currentState;
}
};
const App: React.FC = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
// createContextを定義したファイルをインポートしてProviderとすることでvalueを子コンポーネント以下で受け取れるようにする
// useReducerのアクションをグローバルに使いたい場合はまずuseReducerのstateとdispatchをvalueの引数にする
<AppContext.Provider value={{ state, dispatch }}>
<div className="App">
<header className="App-header">
<img src={logo} alt="logo" className="App-logo" />
Count {state.count}
<CompB />
</header>
</div>
</AppContext.Provider>
);
};
export default App;
AppContext.tsx
// useContextのためにまずはcreateContextを定義したファイルを作る
import { createContext } from "react";
// Reducerをグローバルに使いたい場合の設定、同じことを書いているのでおそらくReducerのファイルと統合する方が吉
const initialState = { count: 0 };
type ACTIONTYPE =
| { type: "add_1"; payload?: number }
| { type: "multiple_3"; payload?: number }
| { type: "reset"; payload?: typeof initialState };
// Context.Providerのvalueの引数は{}なので型アサーションする方が丸い。
// interfaceで定義してnullとのunionもありだが、初期値が必要なのと、nullオブジェクトは許容できませんというエラーも起きてしまうのでとりあえずアサーション。
const AppContext = createContext(
{} as {
//state:numberでもよし。ReducerのStateに当たる部分に応じて適切に
state: typeof initialState;
// dispatchの型はReact.Dispatchらしい。引数で<ACTIONTYPE>を指定する。
dispatch: React.Dispatch<ACTIONTYPE>;
}
);
export default AppContext;
上記の別パターン
const initialState = { count: 0 };
type ACTIONTYPE =
| { type: "add_1"; payload?: number }
| { type: "multiple_3"; payload?: number }
| { type: "reset"; payload?: typeof initialState };
interface AppContextInterface {
state: typeof initialState;
dispatch: React.Dispatch<ACTIONTYPE>;
}
const AppContext = createContext<AppContextInterface | null>(null);
実際にuseContextを用いてdispatch関数を行う子コンポーネント
import React, { useContext } from "react";
import AppContext from "../contexts/AppContext";
const CompC = () => {
// Context.Providerのvalueからパスしてもらう。
const { dispatch } = useContext(AppContext);
return (
<div>
{/* <p>test</p> */}
{/* あとは普通にdispatchとして使う */}
<button onClick={() => dispatch({ type: "add_1" })}>Add + 1</button>
<button onClick={() => dispatch({ type: "multiple_3" })}>
Multiple * 3
</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>{" "}
</div>
);
};
export default CompC;
import React from "react";
import CompC from "./CompC";
const CompB = () => {
return (
<div>
<CompC />
</div>
);
};
export default CompB;
Redux CombineReducer と useReducer の場合
このケースは一見先程のパターンと同じなのかと思ったらそれだと上手く行かずに詰まってしまったので、講師の方にお願いしてソースだけ頂いた。
結論から言うとReducer 部分で state に初期値のプロパティを引数として直接設定することで、型推論を促すということでした。
確かに各 Reducer の state 部分に入るプロパティの型自体は変わらないので typeof など回りくどいことをする必要はないなとは思いました。
この 1 件で TypeScript はなんとなく
- 決められる or 決めるべきところは型を定義して、推論に任せてもいいところはそれを促す
という書き方をするものだなというのを感じました。
App.tsx
import { useReducer, useCallback } from "react";
import "./App.css";
import rootReducer from "./reducers/index";
import { SELL_MEAT, SELL_VEGETABLE } from "./reducers/actionTypes";
function App() {
const initialState = {
reducerMeat: { numOfMeat: 30 },
reducerVegetable: { numOfVegetable: 25 },
};
const [state, dispatch] = useReducer(rootReducer, initialState);
return (
<div className="App">
<header className="App-header">
<button
onClick={useCallback(() => {
dispatch({ type: SELL_MEAT });
}, [])}
>
Sell meat
</button>
Today's Meat: {state.reducerMeat.numOfMeat}
<button
onClick={useCallback(() => {
dispatch({ type: SELL_VEGETABLE });
}, [])}
>
Sell vegetable
</button>
Today's Vegetable: {state.reducerVegetable.numOfVegetable}
</header>
</div>
);
}
export default App;
各Reducer
import { ACTIONTYPE, SELL_MEAT } from "./actionTypes";
const reducerMeet = (state = { numOfMeat: 0 }, action: ACTIONTYPE) => {
switch (action.type) {
case SELL_MEAT:
return { ...state, numOfMeat: state.numOfMeat - 1 };
default:
return state;
}
};
export default reducerMeet;
import { ACTIONTYPE, SELL_VEGETABLE } from "./actionTypes";
const initialState = { numOfVegetable: 0 };
const reducerVegetable = (state = initialState, action: ACTIONTYPE) => {
switch (action.type) {
case SELL_VEGETABLE:
return {
...state,
numOfVegetable: state.numOfVegetable - 1,
};
default:
return state;
}
};
export default reducerVegetable;
import { combineReducers } from "redux";
import reducerMeat from "./reducerMeat";
import reducerVegetable from "./reducerVegetable";
const rootReducer = combineReducers({
reducerMeat,
reducerVegetable,
});
export default rootReducer;
ACTIONTYPE
export type ACTIONTYPE = { type: "SELL_MEAT" } | { type: "SELL_VEGETABLE" };
export const SELL_MEAT = "SELL_MEAT";
export const SELL_VEGETABLE = "SELL_VEGETABLE";
外部 API やバックエンドとの連携(ex.DRF)
ここでの悩みは主にイベントでやりとりする引数の型指定。
実際に自分なりに考えて書いたのが以下の通り。
要点は
- 外部 API 等からデータを持ってくるときは必ずそのデータ型を interface 等で用意する
- 上記は例えば元 Json データ等があるならそれを別ファイルに保存してインポートし、typeof などをするのもアリ
- 各イベントの型は VScode 上で hover して確認して適切に定義する。
- どうしても困ったら any。ただし、ベターではあるかもしれないけどベストではない。
- input タグ絡みの値は string 扱いになるので適宜対応が必要
- onClick 等、各イベント属性でアロー関数定義の関数を呼び出す際は TypeScript だと Void 扱いになるので呼び出す際にもアロー関数で呼び出す
といった感じ。
DrfApiFetch.tsx
import React, { useState, useEffect } from "react";
import axios from "axios";
const DrfApiFetch = () => {
import React, { useState, useEffect } from "react";
import axios from "axios";
const DrfApiFetch = () => {
interface Task {
// any型を嫌うならこっちにすべき?
// id: number | string;
id: number;
title: string;
}
// APIからの返り値はまず、一括取得等のObjectのリストになる場合はinterfaceで定義してinterface[]で定義
const [tasks, setTasks] = useState<Task[]>([]);
// 次に単一データの場合は初期値が空のObjectであった場合は当然型チェックでエラーが出るのでanyにしておく、理由は下記。
const [selectedtask, setSelectedTask] = useState<any>({});
// React.ChangeEvent<HTMLInputElement>型の要素が入るが、初期値を定義したいのでanyにしておく
// これはinputタグから受け取る値がstring型になり、かつidプロパティの初期値は空にしたいのだが、そうするとその初期値自体もstring型になってしまうため
const [editedTask, setEditedTask] = useState<any>({
id: "",
title: "",
});
// inputタグをEventHandlerしたいならこれで定義(inputタグからの値はstring扱いされる)
const [id, setId] = useState<string | number>(1);
const getTaskList = async () => {
try {
const res = await axios.get("http://127.0.0.1:8000/api/tasks/", {
headers: {
Authorization: "Token ~",
},
});
setTasks(res.data);
} catch (error) {
console.log(error);
}
};
const ChangeIdHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setId(e.target.value);
};
const ChangeTitleHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const name = e.target.name;
setEditedTask({ ...editedTask, [name]: value });
};
const getTask = async () => {
try {
const res = await axios.get(`http://127.0.0.1:8000/api/tasks/${id}`, {
headers: {
Authorization: "Token ~",
},
});
setSelectedTask(res.data);
} catch (error) {
console.log(error);
}
};
const newTask = async (task: Task) => {
const data = {
title: task.title,
};
try {
const res = await axios.post(`http://127.0.0.1:8000/api/tasks/`, data, {
headers: {
"Content-Type": "application/json",
Authorization: "Token ~",
},
});
setTasks([...tasks, res.data]);
setEditedTask({
id: "",
title: "",
});
} catch (error) {
console.log(error);
}
};
const editTask = async (task: Task) => {
try {
const res = await axios.put(
`http://127.0.0.1:8000/api/tasks/${task.id}/`,
task,
{
headers: {
"Content-Type": "application/json",
Authorization: "Token ~",
},
}
);
setTasks(
tasks.map((task) => (task.id === editedTask.id ? res.data : task))
);
setEditedTask({
id: "",
title: "",
});
} catch (error: any) {
const errorMessage = error.message;
console.log(errorMessage);
}
};
const deleteTask = async (id: number) => {
try {
const res = await axios.delete(`http://127.0.0.1:8000/api/tasks/${id}`, {
headers: {
Authorization: "Token ~",
},
});
setTasks(tasks.filter((task) => task.id !== id));
setSelectedTask({});
if (editedTask.id !== id) {
setEditedTask({ id: "", title: "" });
}
} catch (error) {
console.log(error);
}
};
useEffect(() => {
getTaskList();
}, []);
return (
<div>
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.title} {task.id}
{/* 定義した関数の返り値の型を指定していない場合(=void)、onClick属性でそのまま指定すると関数扱いにならないのでアローで呼び出し直す */}
<button onClick={() => deleteTask(task.id)}>
<i className="fas fa-trash-alt"></i>
</button>
<button onClick={() => setEditedTask(task)}>
<i className="fas fa-pen"></i>
</button>
</li>
))}
</ul>
<p>Set Id</p>
<input type="text" value={id} onChange={ChangeIdHandler} />
<br />
<button type="button" onClick={() => getTask()}>
Get Task
</button>
<br />
<br />
<input
type="text"
name="title"
value={editedTask.title}
onChange={ChangeTitleHandler}
placeholder="New Title"
/>
{editedTask.id ? (
<button type="button" onClick={() => editTask(editedTask)}>
Edit Task
</button>
) : (
<button type="button" onClick={() => newTask(editedTask)}>
Create Task
</button>
)}
<br />
<h3>
{selectedtask.title} {selectedtask.id}
</h3>
</div>
);
};
export default DrfApiFetch;
最後に
TypeScript に本格的に触れてからまだ 1 週間も経ってないですが、慣れるまでにはなかなか戸惑うことが多いなと感じました。
ともあれ、一部助けをお借りしたものの自分で JS を TS 化していく中で色々と感じも掴めたのでそれを持って
[JIRA 編]React Hooks/TypeScript + Django REST API で作るオリジナル JIRA
こちらのより実践的な内容に行きたいと思いました。
参考
any 型で諦めない React.EventCallback
Type Assertion(型アサーション)
typescript-cheatsheets
React の標準機能(useContext/useReducer)でステート管理[TypeScript 版]
Interpreting Errors
React に TypeScript を導入【Tips 集】