React 入門
TypeScript コメント掲示板アプリの開発
コメント投稿機能の実装
いよいよ新規のコメントを追加する機能を作成します.この処理は少々難解です.
まず,コメントの一覧は CommentListPage コンポーネントで管理しているので,CommentListPage コンポーネントの中に登録処理を行う関数 (handleCreateFormSubmit) を作成します.現時点の処理内容はこの関数が実行されたことがわかるようにログに出力するだけです.
src/components/CommentListPage.tsx(抜粋)
const handelCreateFormSubmit = () => {
console.log('CommentListPage : handelCreateFormSubmit');
}
新たに登録するデータは CreateForm で管理されるので,CreateForm に今作成した handleCreateFormSubmit 関数を渡しますが,CreateForm では onSubmit という名称で handleCreateFormSubmit 関数が呼び出されるようにします.
src/components/CommentListPage.tsx(抜粋)
<CreateForm
onSubmit={handelCreateFormSubmit}
/>
次に,CreateForm コンポーネントでは CommentListPage コンポーネントから渡された onSubmit 関数を props
で受け取ります.このために,先頭の import
文の直後(CreatForm
関数定義より前)でインターフェースを定義します.現時点では onSubmit
関数から返す値はないので void
にしておきます.
src/components/CreateForm.tsx(抜粋)
interface CreateFormProps {
onSubmit: () => void;
}
CreateForm
関数は CreateFormProps
型の props
を引数として受け取り,関数コンポーネント React.FC
を返すので,関数の定義を次の通り修正します.
src/components/CreateForm.tsx(抜粋)
const CreateForm: React.FC<CreateFormProps> = (props) => {
つまり,CreateForm.tsx からは props.onSubmit()で CommentListPage の handleCreateFormSubmit を実行できることを意味します.
CreateForm コンポーネントでフォームのボタンが押されたときに,CreateForm 内でこれから定義する handleSubmit 関数が実行されるようにします.
src/components/CreateForm.tsx(抜粋)
<form onSubmit={handleSubmit}>
CreateForm で handleSubmit 関数を定義します.この中から,CommentListPage の handleCreateFormSubmit 関数を呼び出すべく props.onSubmit()
を実行します.
src/components/CreateForm.tsx(抜粋)
const handleSubmit = () => {
console.log("CreateForm : handlSubmit");
props.onSubmit();
}
この段階で一旦ブラウザで動作を確認します.ブラウザのコンソールには CommentListPage の handelCreateFormSubmit が実行されたことが一瞬だけ表示されます.しかし,フォームに入力した文字列が消えて,コンソールのログもすぐに消えてしまいました.これはボタンを押したことで新たなページへ遷移したためです.CommentForm に event.preventDefault()
を追加することで,ボタンを押したときの標準の動作であるページの再読み込みが行われないようにできます.
src/components/CreateForm.tsx(抜粋)
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log("CreateForm : handlSubmit");
props.onSubmit();
};
上の FormEvent
が利用できるように先頭でインポートします.
src/components/CreateForm.tsx(抜粋)
import { useState, FormEvent } from 'react';
再びブラウザで確認すると,ボタンを押したときに実行したい関数を呼び出せていることがわかりました.
CreateForm コンポーネントでは,title
と body
で内部状態を管理しているので,この2つを CommentListPage の関数へ渡さなければなりません.このためにインターフェースを修正します.
src/components/CreateForm.tsx(抜粋)
interface CreateFormProps {
onSubmit: (title: string, body: string) => void;
}
CreateForm コンポーネントから title
と body
を引数で onSubmit
関数に渡します.
src/components/CreateForm.tsx(抜粋)
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// console.log("CreateForm : handlSubmit");
props.onSubmit(title, body);
};
CommentListPage の handelCreateFormSubmit 関数では,CreateForm から渡された title
と body
を受け取って,ログへ出力します.
src/components/CommentListPage.tsx(抜粋)
const handelCreateFormSubmit = (title: string, body: string) => {
// console.log('CommentListPage : handelCreateFormSubmit');
console.log('title', title);
console.log('body', body);
}
現時点での CommentListPage.tsx の全体像を確認します.
CommentListPage.tsx
import React from 'react'
import Comment from './Comment';
import CreateForm from './CreateForm';
const CommentListPage: React.FC = () => {
const results = {
"count":10,
"next":"http://127.0.0.1:8000/comments/?page=2",
"previous":null,
"results":[
{
"id":9,
"title":"9個目のコメント",
"body":"コメントの本文9",
"updated_at":"2023-11-21T11:20:00"
},
{
"id":10,
"title":"10個目のコメント",
"body":"コメントの本文10",
"updated_at":"2023-11-21T11:10:00"
}
]
};
const commentItems = results.results.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
/>
)
});
const handelCreateFormSubmit = (title: string, body: string) => {
// console.log('CommentListPage : handelCreateFormSubmit');
console.log('title', title);
console.log('body', body);
}
return (
<div className="container">
<h1>コメント一覧</h1>
{commentItems}
<CreateForm
onSubmit={handelCreateFormSubmit}
/>
</div>
)
}
export default CommentListPage
また,CreateForm.tsx の全体像も確認します.
CreateForm.tsx
import React from 'react'
import { useState, FormEvent } from 'react';
interface CreateFormProps {
onSubmit: (title: string, body: string) => void;
}
const CreateForm: React.FC<CreateFormProps> = (props) => {
const [title, setTitle] = useState<string>('');
const [body, setBody] = useState<string>('');
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.currentTarget.value);
};
const handleBodyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setBody(event.currentTarget.value);
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit(title, body);
};
return (
<>
<hr />
<article>
<h2>コメントの新規投稿</h2>
<form onSubmit={handleSubmit}>
<div>
<label>
タイトル:
<input
type="text"
className="textInput"
value={title}
onChange={handleTitleChange}
/>
</label>
</div>
<div>
<label>
本文:
<input
type="text"
className="textInput"
value={body}
onChange={handleBodyChange}
/>
</label>
</div>
<div>
<button>コメントの追加</button>
</div>
</form>
</article>
</>
)
}
export default CreateForm
ブラウザで確認します.タイトルと本文を入力してボタンを押すと,その内容がコンソールに出力されました.
これまでの作業で,フォームに入力されたタイトルと本文のデータを CommentListPage の handelCreateFormSubmit 関数で受け取ることができるようになりました.コメントの一覧情報は CommentListPage コンポーネントで管理しますが,現時点ではコメント一覧のオブジェクト定数 (results
) は次のように定義しています.
src/components/CommentListPage.tsx(抜粋)
const results = {
"count":10,
"next":"http://127.0.0.1:8000/comments/?page=2",
"previous":null,
"results":[
{
"id":9,
"title":"9個目のコメント",
"body":"コメントの本文9",
"updated_at":"2023-11-21T11:20:00"
},
{
"id":10,
"title":"10個目のコメント",
"body":"コメントの本文10",
"updated_at":"2023-11-21T11:10:00"
}
]
};
ここからは CommentListPage 内でコメントの一覧を useState()
で管理しますが,初期値は上のオブジェクト定数をそのまま利用します.また,プログラムの先頭で useState
をインポートします.さらに,results
の型を定義するために Results
というインターフェースを準備します.
src/components/CommentListPage.tsx(抜粋)
import React from 'react'
import { useState } from 'react';
import Comment from './Comment';
import CreateForm from './CreateForm';
interface CommentData {
id: number;
title: string;
body: string;
updated_at: string;
}
interface Results {
count: number;
next: string | null;
previous: string | null;
results: CommentData[];
}
const CommentListPage: React.FC = () => {
const [results, setResults] = useState<Results>({
"count":10,
"next":"http://127.0.0.1:8000/comments/?page=2",
"previous":null,
"results":[
{
"id":9,
"title":"9個目のコメント",
"body":"コメントの本文9",
"updated_at":"2023-11-21T11:20:00"
},
{
"id":10,
"title":"10個目のコメント",
"body":"コメントの本文10",
"updated_at":"2023-11-21T11:10:00"
}
]
});
投稿機能を実装します.下の55行目では,管理されているコメントの一覧を ...
というスプレッド構文で配列に展開して newComments
という定数に代入しています.56行目から61行目の { ... }
で囲まれる部分で,新たなコメントオブジェクトを生成しています.このとき,id
には値が重複しないように投稿日時をミリ秒単位で取得したものを設定します.title
と body
にはフォームから受け取った値を設定します.updated_at
は現時点では利用しないので適当な日時を設定しておきます(後ほど API から受け取ることができるようになります).さらに,56行目では newComments.unshift()
を使って配列の先頭に新たなコメントオブジェクトを追加しています.62行目から67行目で useState の results
を更新するための新たなデータを作成します.ここでは,コメント総数が1増えるようにし,results.results
に新たなコメントが追加されたオブジェクトの配列を設定します.最後に68行目でコンポーネントで管理される results
の値を更新すると画面の必要部分が更新されるようになります.(56行目で unshift()
の代わりに push()
を使うと最後に追加されるのでこれも試してください.)
src/components/CommentListPage.tsx
import React from 'react'
import { useState } from 'react';
import Comment from './Comment';
import CreateForm from './CreateForm';
interface CommentData {
id: number;
title: string;
body: string;
updated_at: string;
}
interface Results {
count: number;
next: string | null;
previous: string | null;
results: CommentData[];
}
const CommentListPage: React.FC = () => {
const [results, setResults] = useState<Results>({
"count":10,
"next":"http://127.0.0.1:8000/comments/?page=2",
"previous":null,
"results":[
{
"id":9,
"title":"9個目のコメント",
"body":"コメントの本文9",
"updated_at":"2023-11-21T11:20:00"
},
{
"id":10,
"title":"10個目のコメント",
"body":"コメントの本文10",
"updated_at":"2023-11-21T11:10:00"
}
]
});
const commentItems = results.results.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
/>
)
});
const handelCreateFormSubmit = (title: string, body: string) => {
// console.log('CommentListPage : handelCreateFormSubmit');
// console.log('title', title);
// console.log('body', body);
const newComments = [...results.results]; // スプレッド構文でコメントの配列だけを取り出す
newComments.unshift({ // unshift で先頭に追加,push では最後に追加
id: Date.now(),
title: title,
body: body,
updated_at: "2024-03-18T12:00:00",
});
const newResults = {
"count": results.count + 1, // コメント数は1増やす
"previous": results.previous,
"next" : results.next,
"results": newComments, // これがコメントの配列
};
setResults(newResults); // 画面を更新
}
return (
<div className="container">
<h1>コメント一覧</h1>
{commentItems}
<CreateForm
onSubmit={handelCreateFormSubmit}
/>
</div>
)
}
export default CommentListPage
ブラウザで動作を確認します.タイトルと本文を入力してボタンを押すと新たなコメントが先頭に追加されるようになりました.
しかしながら,コメントを追加した後にはテキストボックスを空にしたほうが良いでしょう.CreateForm.tsx にテキストボックスを空にするコードを追加します.つまり,setTitle()
と setBody()
に空の文字列を渡して内部状態を更新することで,テキストボックスをクリアできます.
src/components/CreateForm.tsx(抜粋)
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit(title, body);
setTitle(''); // 入力フォームを空に戻す
setBody(''); // 入力フォームを空に戻す
};
さらに,登録後はタイトルのテキストボックスにフォーカスが当たる(つまり入力できるように選択される)とより使いやすくなるでしょう.このためには useRef
という React のフックを利用します.まず,CreateForm コンポーネントの先頭で useRef
をインポートします.
src/components/CreateForm.tsx(抜粋)
import { useState, useRef, FormEvent } from 'react';
次に参照オブジェクトを生成します.
src/components/CreateForm.tsx(抜粋)
const inputTitleRef = useRef<HTMLInputElement>(null);
上で定義した参照オブジェクトがタイトルのテキストボックスを参照するようにします.
src/components/CreateForm.tsx(抜粋)
<input
type="text"
className="textInput"
value={title}
onChange={handleTitleChange}
ref={inputTitleRef}
/>
投稿完了後にテキストボックスにフォーカスを当てます.
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit(title, body);
setTitle(''); // 入力フォームを空に戻す
setBody(''); // 入力フォームを空に戻す
if (inputTitleRef.current) {
inputTitleRef.current.focus(); // フォーカスを当てる
}
};
これで投稿機能が完成しました.投稿したコメントが先頭に追加されます.ただし,現時点ではブラウザでページの再読み込みを行うと初期状態に戻ることを確認してください.
この時点での CommentListPage.tsx 全体のコードを確認します.
src/components/CommentListPage.tsx
import React from 'react'
import { useState } from 'react';
import Comment from './Comment';
import CreateForm from './CreateForm';
interface CommentData {
id: number;
title: string;
body: string;
updated_at: string;
}
interface Results {
count: number;
next: string | null;
previous: string | null;
results: CommentData[];
}
const CommentListPage: React.FC = () => {
const [results, setResults] = useState<Results>({
"count":10,
"next":"http://127.0.0.1:8000/comments/?page=2",
"previous":null,
"results":[
{
"id":9,
"title":"9個目のコメント",
"body":"コメントの本文9",
"updated_at":"2023-11-21T11:20:00"
},
{
"id":10,
"title":"10個目のコメント",
"body":"コメントの本文10",
"updated_at":"2023-11-21T11:10:00"
}
]
});
const commentItems = results.results.map((comment) => {
return (
<Comment
key={comment.id}
comment={comment}
/>
)
});
const handelCreateFormSubmit = (title: string, body: string) => {
const newComments = [...results.results]; // スプレッド構文でコメントの配列だけを取り出す
newComments.unshift({ // unshift で先頭に追加,push では最後に追加
id: Date.now(),
title: title,
body: body,
updated_at: "2024-03-18T12:00:00",
});
const newResults = {
"count": results.count + 1, // コメント数は1増やす
"previous": results.previous,
"next" : results.next,
"results": newComments, // これがコメントの配列
};
setResults(newResults); // 画面を更新
}
return (
<div className="container">
<h1>コメント一覧</h1>
{commentItems}
<CreateForm
onSubmit={handelCreateFormSubmit}
/>
</div>
)
}
export default CommentListPage
CreateForm.tsx の全体も確認します.
src/components/CreateForm.tsx
import React from 'react'
import { useState, useRef, FormEvent } from 'react';
interface CreateFormProps {
onSubmit: (title: string, body: string) => void;
}
const CreateForm: React.FC<CreateFormProps> = (props) => {
const [title, setTitle] = useState<string>('');
const [body, setBody] = useState<string>('');
const inputTitleRef = useRef<HTMLInputElement>(null);
const handleTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTitle(event.currentTarget.value);
};
const handleBodyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setBody(event.currentTarget.value);
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
props.onSubmit(title, body);
setTitle(''); // 入力フォームを空に戻す
setBody(''); // 入力フォームを空に戻す
if (inputTitleRef.current) {
inputTitleRef.current.focus(); // フォーカスを当てる
}
};
return (
<>
<hr />
<article>
<h2>コメントの新規投稿</h2>
<form onSubmit={handleSubmit}>
<div>
<label>
タイトル:
<input
type="text"
className="textInput"
value={title}
onChange={handleTitleChange}
ref={inputTitleRef}
/>
</label>
</div>
<div>
<label>
本文:
<input
type="text"
className="textInput"
value={body}
onChange={handleBodyChange}
/>
</label>
</div>
<div>
<button>コメントの追加</button>
</div>
</form>
</article>
</>
)
}
export default CreateForm
もう一度投稿すると,テキストボックスがクリアされて,フォーカスがタイトルに当たりました.タイトルを入力後 Tab キーでフォーカスを本文に移動します.本文を入力後に Enter で投稿できるので,キーボード操作だけで,連続してコメントを投稿できることを試してください.
なお,ブラウザでページを再読込すると初期状態に戻ります.