TODOアプリを真面目に作ったので覚え書き(Reactクライアント編)
前回
Spring Boot x PostgreSQL x Dockerで動くRestAPIを作った
※こちらも前回同様指摘とアドバイス歓迎です。
GitHubリポジトリ
作る前に
前回の記事の時点でReactアプリのコンテナは作成したが、
ホットデプロイは設定されていないので追加する。
設定の仕方を調べた結果として、方法に変化があるのを確認。package.json
かdocker-compose.yml
のenvironment:
項目で以下指定をする。今回はjsonで指定したが、変更がすぐ反映されてるのを確認できた。
WATCHPACK_POLLING=true
"start": "WATCHPACK_POLLING=true react-scripts start",
CORSを許可する
CORSって何?って人は色々調べてみて下さい。
簡単に説明すると異なるオリジンの間で通信をする仕組みのこと
ドメインがexample.com
だとしたらオリジンはhttp://example.com:1234
を指す
APIを起動させた状態でReactアプリ側からそのままリクエストを送る
// 適当にこれをApp()の中で呼ぶ
const getData = async () => {
try {
const response = await fetch('http://localhost:8080/api/todo/list');
console.log(response);
if (!response.ok) {
console.log('status');
console.log(response.status);
}
console.log('JSON data');
console.log(await response.json());
} catch (error) {
console.log(error);
throw error;
}
};
APIがCORSを許可していないので当然こんなふうに怒られてしまう。
卒制でAPIGateWay使ってた時もこれに悩まされていたのでもう慣れてるぜ!
Spring側に設定を追加する
クライアントのオリジンの通信許可する設定を記述する
クラス名の上にアノテーション書くだけ。
@CrossOrigin // すべてのオリジンが許可される
@CrossOrigin(origins = "http://localhost:3000") // 特定のオリジンを許可する
丁寧に設定したい場合
通信するうえでどこまで許可するかを細やかに設定したい場合は
WebMvcConfigurer
を継承したConfigクラスに以下の関数を書く。
メソッドの内容は名前の通りです。
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true);
}
通信が許可されたので、データが取得できるようになった。これを元にして
一覧画面を作る。
いよいよコーディング
APIを呼ぶ上で頻出するエンドポイントは変数に格納して扱うようにした。
const APICONF ={
BASE_ENDPOINT: "http://localhost:8080/api/todo/",
}
export default APICONF;
一覧
typeで先にプロパティと型を定義しておくことでフロントエンドのバグ発生率を下げて
安全な開発ができる。コード補完も効くし、コーディングの段階で間違いにも気付ける。
また、APIのレスポンスもあらかじめ調べて定義しておけば、実際にAPIリクエスト送った際の
ハンドリングにおいてもバグを減らせる。
個人的な感想だが、生JSを使っている時に値をdata.hogefield
のように取得する時の
不安な感じがないので、精神衛生にも良いと思った。
TODOコンポーネントを定義する
// 表示用のTODOを定義
type Todo = {
id:number,
todo:string,
name:string,
}
export const TodoItem = (props : Todo) =>{
const {id, todo, name} = props;
return(
<div>
<p>
TODO-ID: {id} TODO: {todo} postUser: {name}
</p>
</div>
);
}
コンポーネントを使って実際に表示
テキストボックスも後でTODO登録時に使うので先に作ってしまう。
値に変化の必要な要素はuseStateで状態管理する形を取る。
仕様上、2回Fetchされるようだが、今回は見なかったことに。StrictModeをオフにしたり、
2回目の実行結果を無視したりと、色々とやりようはあるようだ。
// レスポンスとして帰って来るTODOを定義
type TodoData = {
id: number,
todo_context: string,
post_user_name: string,
created_at: string,
updated_at: string
}
const App = () => {
// todoデータの取得とセット。そして表示
// アプリケーションとしてデータを保持する状態の管理を行う
const [todos, setTodos] = useState<TodoData[]>([]);
const [todoContext, setTodoContext] = useState<string>();
const [todoUserName, setUserName] = useState<string>();
// 入力チェック
const callPost = () => {
console.log(todoContext)
console.log(todoUserName)
}
// 画面表示のときに読み込む
// fetch2回呼ばれる問題
// https://ja.react.dev/reference/react/useEffect#my-effect-runs-twice-when-the-component-mounts
useEffect(() => {
const fetchTodos = async () => {
try {
const response = await fetch(APICONF.BASE_ENDPOINT + 'list');
if (!response.ok) {
console.log(response)
}
const data = await response.json();
setTodos(data);
} catch (error) {
console.error(error);
}
};
fetchTodos();
}, []);
// .map() 配列の要素に対して一つ一つ処理ができる
return (
<div className='todo-cotent' style={{ textAlign: "center" }}>
<h1>Todo</h1>
<div className="post">
<input value={todoContext} onChange={(e) => setTodoContext(e.target.value)} type="text" placeholder="TODO内容を書き込む" />
<input value={todoUserName} onChange={(e) => setUserName(e.target.value)} type="text" placeholder="ニックネームをどうぞ" />
<button onClick={callPost}>TODO登録する</button>
</div>
<ul>
// 取得したTODOリストのデータをコンポーネントに詰め込んでる
{todos.map(todo => (
<TodoItem
id={todo.id}
todo={todo.todo_context}
name={todo.post_user_name}
/>
))}
</ul>
</div>
);
};
export default App;
無事に一覧データがそれっぽく出てきた。デザインは後でこだわることに
※ボタン出てるのは開発しながら記事書いてるため
登録、削除
まず先に自作APIの仕様に合わせてリクエストを送る。
送信時のプロパティ定義が曖昧なので調べながら書いてる。
独自レスポンスを確認したいのでメソッドチェーンを使って data
を取得している。
処理の流れが似てるのでリクエストを送る本筋部分の処理は関数化してもよいかも
// リクエスト形式
type RequestBody = {
todo_context: string,
post_user_name: string,
}
// async awaitで処理完了後に次の処理に移れる
export const sendPostTodo = async (todoContext:string, todoUserName:string) => {
// 定義から実際に送信するボティを作る
// 一応勉強のためにType作ったけどこれ必要だったかは怪しい
const sendBody: RequestBody = {
todo_context: todoContext,
post_user_name: todoUserName
}
await fetch(APICONF.BASE_ENDPOINT + "create", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 'Authorization' : TOKEN
},
body: JSON.stringify(sendBody)
}).then((response) => {
if (!response.ok) {
console.log(response);
}else {
console.log('クライアントから登録');
}
return response.json();
}).then((data) => {
console.log(data);
}).catch((error) => {
console.log(error);
});
}
// 削除はシンプル
export const sendDeleteTodo = async (id:number) => {
await fetch(APICONF.BASE_ENDPOINT + "delete/" + id.toString(), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
// 'Authorization' : 'APITOKEN'
},
}).then((response) => {
if (!response.ok) {
console.log(response);
}else {
console.log('クライアントから削除');
}
return response.json();
}).then((data) => {
console.log(data);
}).catch((error) => {
console.log(error);
});
}
処理を作ったので呼び出せるようにメイン画面とTODOコンポーネントを書き換える
const callPost = () => {
console.log(todoContext)
console.log(todoUserName)
// undefined時対応も書いておく。書かないとTypeScriptが怒る
sendPostTodo(todoContext ?? "", todoUserName ?? "名無しのTODOer")
}
const callDelete = () =>{
sendDeleteTodo(id);
}
return(
<div>
<p>
TODO-ID: {id} TODO: {todo} postUser: {name}
</p><button onClick={callDelete}>DONE</button>
</div>
);
動作確認
TODO登録と削除を行ったことでデータ数に変化があるのがわかる。
これでTODOアプリの土台はできた。右に出てるWarnも直す。
Warn直し: Warning: A component is changing an uncontrolled ...
入力要素の値の制御ができていないのが原因。
Stateの初期値を定義していないと入力値の制御をしている扱いにはならないようだ。
なので設定する。
const [todoContext, setTodoContext] = useState<string>();
const [todoUserName, setUserName] = useState<string>();
↓
const [todoContext, setTodoContext] = useState<string>("");
const [todoUserName, setUserName] = useState<string>("");
こっちもいらなくなる。
// undefined時対応も書いておく
sendPostTodo(todoContext ?? "", todoUserName ?? "名無しのTODOer")
↓
// undefined時対応も書いておく
sendPostTodo(todoContext, todoUserName)
Warn直し: Warning: Each child in a list should have a unique "key" prop.
リスト形式で何かしら表示してる時に一意な値を定義していないのが原因。
データの動きをReactが識別するのに必要らしい。調べた感じこれ出ている人は多かった。
表示側でキーを指定する
<ul>
{todos.map(todo => (
<div key={todo.id}>
<TodoItem
id={todo.id}
todo={todo.todo_context}
name={todo.post_user_name}
/>
</div>
))}
</ul>
結果。コンソールがきれい。
今回は簡単なアプリだからいいが、より複雑な実用性あるものを作ろうとすると警告は避けられなそうな感じはする。現に会社で使っているグループウェアはWARNとエラーがそこそこ出ている。
アプリとして一工夫してみる
訳あって認証を作るのに断念した代わりに、アプリの実用性を上げてみる。
以下を実現した。GIFは完成品のデモ。
- 画面のリアルタイム更新
- CRUDにする
- そこそこキレイなデザインにする
- TODOデータ
- MarkDown書式に適応する
真面目に作るための下準備
デザインとルーティングのために依存関係を準備
Docker環境で依存環境を入れるコマンド形式。
違う形式ご存知のかたは教えていただきたいです。
docker exec -it <ReactAppのコンテナ名> sh -c "npm install <依存関係>"
インストールした一覧
# ルーティング用
npm install react-router-dom
# デザイン用
npm install npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
# MarkDown書式適用用
npm install react-markdown
完全にCRUDできるアプリにする(編集の実装)
一旦簡易的なAPI実装ということで保留にしていた編集を実装する。
API側
Mapper, Service, Controllerに編集できる処理を追加。
// 更新件数を取り扱う処理をしていないのでvoidでいいかも
@Update("UPDATE api.todo SET (todo_context, updated_at) = (#{todoContext}, now()) WHERE id = #{id}")
Integer update(TodoData entity);
public void update(TodoData entity){
todoMapper.update(entity);
}
@PostMapping("api/todo/update")
@ResponseBody
public TodoResponseBody update(@RequestBody @Validated TodoData entity, BindingResult result) {
var resultById = todoService.select(entity.getId());
// 存在しないデータを編集しようとしてたらエラー
if (resultById == null) {
requestHandler.handleExceptionRequest(TodoAPIMessages.DATA_NOT_FOUND, HttpStatus.NOT_FOUND.value());
}
// 不適切なRequestJSONが送信されたらキャッチ
if (result.hasErrors()) {
// result.getFieldError();
requestHandler.handleExceptionRequest(TodoAPIMessages.WRONG_REQUEST, HttpStatus.BAD_REQUEST.value());
}
todoService.update(entity);
// レスポンスでは、編集前データも表示する
var updatedInfoEntity = todoService.getEditResultEntity(resultById, entity);
var postResponse = requestHandler.createTodoResponseBody(HttpStatus.CREATED.value(), updatedInfoEntity, "UPDATE TODO");;
return postResponse;
}
クライアント側
編集画面と編集リクエストを実装する。
// 編集追加 APIの仕様に合わせ
export const sendEditTodo = async (id: number, todoContext: string): Promise<boolean> => {
let isSendResult: boolean = false;
const sendBody: EditBody = {
id: id,
todo_context: todoContext,
}
await fetch(APICONF.BASE_ENDPOINT + "update", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 'Authorization' : TOKEN
},
body: JSON.stringify(sendBody)
}).then((response) => {
if (!response.ok) {
console.log(response);
} else {
console.log('クライアントから編集');
isSendResult = true;
}
return response.json();
}).then((data) => {
console.log(data);
// internal serverエラーはこっちでキャッチする
if (data.status == 500) {
let message = "登録に失敗しました。"
if (todoContext.length > 500) {
message += "\nTODO内容は500文字以下で入力してください。"
}
// alertで簡易エラー表示
alert(message);
}
}).catch((error) => {
console.log(error);
});
return isSendResult;
}
画面。IDはパス変数で取得する
// 編集画面
const EditTodo = () => {
const { id } = useParams<{ id: string }>();
const [todoContext, setTodoContext] = useState<string>("");
// 入力値の保持のため。stateにTODO内容を取得してセットする
const selectTodoContextById = async () => {
try {
const response = await fetch(APICONF.BASE_ENDPOINT + id);
if (!response.ok) {
console.log(response)
}
const data = await response.json();
setTodoContext(data.todo_context)
} catch (error) {
console.error(error);
}
}
useEffect(() => {
selectTodoContextById();
}, []);
const callEdit = async () => {
console.log(todoContext)
// 編集が成功したら一覧に戻る
const numberTypeId:number = Number(id);
const editIsSuccess = await sendEditTodo(numberTypeId, todoContext)
if (editIsSuccess) {
setTodoContext("");
window.location.href = "/";
}
}
return (
<div className='todo-cotent' style={{ textAlign: "center" }}>
<div className="post" style={{ paddingTop: 50, paddingBottom: 50 }}>
<div>
<h2>EDIT TODO ID:{id}</h2>
<textarea value={todoContext} onChange={(e) =>
setTodoContext(e.target.value)}
placeholder="TODO内容を書き込む(MAX500)"
style={inputFormStyle}
/>
</div>
<Button variant='contained' color="secondary" onClick={callEdit}>UPDATE</Button>
<Button variant='contained' href="/">BACK</Button>
</div>
</div>
);
};
export default EditTodo;
Todoを個別で選択して確認できるようにする
リスト形式で見ると少し長いTODO内容は見にくい。詳細を確認できるようにする。
詳細の取得に失敗した場合は空のTODOが返ることで例外ハンドリングとしている。
// TODOの個別選択
export const SelectTodo = () => {
const [todo, setTodo] = useState<TodoData>({ id: 0, todo_context: "", post_user_name: "", created_at: "", updated_at: "" });
const { id } = useParams<{ id: string }>();
const selectTodoById = async () => {
try {
const response = await fetch(APICONF.BASE_ENDPOINT + id);
if (!response.ok) {
console.log(response)
}
const data = await response.json();
setTodo(data);
} catch (error) {
console.error(error);
}
}
// idがstringとして取得できない場合は空文字で
useEffect(() => {
selectTodoById();
}, []);
return (
<div key={todo.id} className="todoById">
<div style={{marginLeft:30}}>
<h2>{todo.id} NICKNAME:{todo.post_user_name}</h2>
<Markdown>{todo.todo_context}</Markdown>
<Button variant='outlined' href="/">BACK</Button>
</div>
</div>
);
}
ルーティングを設定して、ボタンで画面遷移
Appはパス設定をメインにする。Link to
やhref
で設定パスを利用する。
const App = () => {
return (
<BrowserRouter>
<Routes>
<Route index element={<TodoHome />} />
<Route path="todo/:id" element={<SelectTodo />} />
<Route path="todo/edit/:id" element={<EditTodo />} />
</Routes>
</BrowserRouter>
)
}
MarkDown書式を使えるようにする
エンジニア系の人なら結構使うと思ってなんとなく足したアイデア。
これはかなりお手軽。 タグで囲むだけでMarkDownになる。
<Markdown>{todo.todo_context}</Markdown>
コードブロックで囲むとフォントはそれっぽくなるが、
シンタックスハイライトは効かない。別途設定が必要のようだ。
TODOリストのリアルタイム更新(state管理)
操作イベントに応じてstateを管理することで実現した。
ここはReactらしく実装をできた気がする。
登録時
登録リクエストを行う関数の戻り値をvoidからbooleanにして
その結果でstate更新をする関数を呼ぶ。
const [todos, setTodos] = useState<TodoData[]>([]);
// 一覧を取得してstateにセットする関数
const getTodos = async () => {
try {
const response = await fetch(APICONF.BASE_ENDPOINT + 'list');
if (!response.ok) {
console.log(response)
}
const data = await response.json();
setTodos(data);
} catch (error) {
console.error(error);
}
}
// post呼び出し
const callPost = async () => {
console.log(todoContext)
console.log(todoUserName)
// 登録が成功したら一覧を更新して、入力フォームを初期化する
const postIsSuccess = await sendPostTodo(todoContext, todoUserName)
if (postIsSuccess) {
getTodos();
setTodoContext("");
setUserName("");
}
}
削除
TODO一つと一覧はコンポーネントが親子関係になっている。
子側でコールバックを定義する。
コールバックが実行されたら、親側state更新処理が実行される仕組みだ。
登録同様こちらも戻り値をvoidからbooleanに変更してる。
type Todo = {
id: number,
todo: string,
name: string,
onDelete: (id: number) => void; // 削除イベントを処理するコールバック関数
}
export const TodoItem = (props: Todo) => {
const { id, todo, name, onDelete } = props;
const callDelete = async () => {
const isDeleteSuccess = await sendDeleteTodo(id);
if (isDeleteSuccess) {
// 削除が完了したら親コンポーネントから渡されたコールバック関数を呼び出す
// HOME画面のstate更新に必要となる
onDelete(id);
}
}...割愛
一覧
// TodoItem コンポーネントから呼び出される削除処理用のコールバック関数
const handleDelete = (deletedTodoId: number) => {
// 削除したID以外のTODOにする。
const updatedTodos = todos.filter(todo => todo.id !== deletedTodoId);
// 更新された一覧をセットする
setTodos(updatedTodos);
}.....割愛
// コンポーネント使用部分
<TodoItem id={todo.id} todo={todo.todo_context} name={todo.post_user_name} onDelete={handleDelete} />
デザイン(実装法抜粋)
styleの指定とMaterialUIを活用して見た目を整える
ヘッダー
公式例を参考にAppBarを設置する。背景色変更とチェックマークアイコンをつけた。
BrowserRouter下がコンテンツ領域なのでそこに定義する。
<BrowserRouter>
<AppBar position="static" style={{ backgroundColor: "#102c42" }}>
<Toolbar>
<Typography variant="h6" component="div" align="center">
TODO LIST
</Typography>
<CheckCircleIcon></CheckCircleIcon>
</Toolbar>
</AppBar>
....割愛
各種ボタン
元のボタンを置き換えた
<Button variant='contained' color="primary" onClick={callDelete}>DONE</Button>
一覧部分
Gridを使用。グリッドコンテナの子にグリッドを置くのが基本。
Todoコンポーネント部分
Gridの中で更に横並びを実現したい為、こちらでもGridコンテナを定義する。
詳細で確認するので、TODO文章量が多い場合は部分表示をする処理を追加
let viewTodo = todo;
const partOfTodo = todo.substring(0, 10) + "...";
// 10文字超えたら部分表示
if (todo.length > 10) {
viewTodo = partOfTodo;
}
<Grid item xs={12} justifyContent={"center"}>
<Paper style={{ width: 550, textAlign: 'center', marginTop: 20, backgroundColor: "#e6f5ff" }}>
<Grid container style={{ height: 100 }}>
<Grid item xs={6} style={{}}>
<p>{viewTodo}</p>
<Button variant='outlined' href={"/todo/" + id}>詳細を見る</Button>
</Grid>
<Grid item xs={2} style={{ marginTop: 20 }}>BY {name}</Grid>
<Grid item xs={2} style={{ marginTop: 20 }}><a href={"/todo/edit/" + id}><EditIcon></EditIcon></a></Grid>
<Grid item xs={2} style={{ marginTop: 20 }}><Button variant='contained' color="primary" onClick={callDelete}>DONE</Button></Grid>
</Grid>
</Paper>
</Grid>
コンポーネントを利用してトップページ側で利用する
{todos.map(todo => (
<Grid container justifyContent={"center"} key={todo.id}>
<div>
<TodoItem id={todo.id} todo={todo.todo_context} name={todo.post_user_name} onDelete={handleDelete} />
</div>
</Grid>
))}
その他style
見た目がある程度整うようにサイズや、余白を指定
共通で使えるスタイルプロパティは変数にした。
例として編集と登録は同じテキストボックスを使っている。
// 編集・TODO作成どっちでも使っているので別ファイルへ
export const inputFormStyle = {
height: 80,
width: 550,
borderRadius: 10,
borderColor: "#f2f2f2",
fontSize: 20
}
-----------------------------------------------
<textarea value={todoContext} onChange={(e) =>
setTodoContext(e.target.value)}
placeholder="TODO内容を書き込む(MAX500)"
style={inputFormStyle}
/>
リファクタリング
ソースに一定数改善点はあるが、明確に分かっている部分にリファクタを行う。
書き込み系処理(登録、編集、削除)はリクエスト送信処理が同じなので、関数化
して利用する。
リクエスト関数
// ? anyにすることでOption引数になる。anyで型も任意になる。
const sendRequest = async (url: string, method: string, body?: any) => {
let isSendResult: boolean = false;
await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
// bodyがあれば送信に含める。
body: body ? JSON.stringify(body) : undefined
}).then((response) => {
if (!response.ok) {
console.log(response);
} else {
isSendResult = true;
}
// responseを返すことでdataに値が入っていく
return response.json();
}).then((data) => {
console.log(data);
// internal serverエラーはこっちでキャッチする
if (data.status == 500) {
handleAlertMessage(method, body);
}
}).catch((error) => {
console.log(error);
});
return isSendResult;
}
// 簡易バリデーション的にalert
// ちなみにサーバサイドAPIでもリクエストボディにバリデーションはかけている。
const handleAlertMessage = (method: string, body?: any) => {
let message = "処理に失敗しました。";
if (method === "POST") {
// POSTならBodyがあるので参照する
const todoContext = body.todo_context;
// 編集の場合は存在しないので空文字にしておく
const todoUserName = body.post_user_name ?? "";
if (todoContext.length > 500) {
message += "\nTODO内容は500文字以下で入力してください。"
}
if (todoUserName.length > 100) {
message += "\nニックネームは100文字以下で入力してください。"
}
} else if (method === "DELETE") {
message = "削除に失敗しました。";
}
alert(message);
}
関数を利用したコード(例:登録、削除)
処理が共通化できるとコードの可読性も上がってるはず。
// async awaitで処理完了後に次の処理に移れる
export const sendPostTodo = async (todoContext: string, todoUserName: string) => {
let isSendResult: boolean = false;
const sendBody: RequestBody = {
todo_context: todoContext,
post_user_name: todoUserName
}
isSendResult = await sendRequest(APICONF.BASE_ENDPOINT + "create", "POST", sendBody);
return isSendResult;
}
// 削除はシンプル
export const sendDeleteTodo = async (id: number) => {
let isDeleteResult: boolean = false;
isDeleteResult = await sendRequest(APICONF.BASE_ENDPOINT + "delete/" + id.toString(), "DELETE");
return isDeleteResult;
}
最後に
今回APIとクライアント両方作ってみての振り返りをまとめる
-
実装を進める中でReactやSpringの仕様、Docker環境面でハマりポイントがあってCRUDするだけでも一から作ると意外と難しい。しかし、ハマって学べた事もあったので今後の開発で活か
したい。 -
Reactのstateやcomponentといった概念は個人的に少し難しいが、理解できると値の受け渡しや、画面処理を適切にわかりやすいコードで実装できる。引き続きReactでコードを書いて、
発展としてNext.jsを始めとした各種.jsフレームワークも利用したい。 -
SpringでAPIを実装したが、処理を書くクラスの場所が適切か不明な点(特にController部分)
があり、自分の情報をアップデートすることでリファクタリングして行こう思った。
最後まで読んでいただいた方には感謝します。
以上です。
Discussion