神戸学院大学 経営学部 林坂ゼミ

React 入門トップページ

« 戻る 次へ »

React 入門

TypeScript コメント掲示板アプリの開発

プログラムの全体像

前のページまでで API からコメントを取得して一覧表示・個別表示したり,編集,削除ができる React のフロントエンドアプリケーションが完成しました.これまで断片的に示していたコードもあるのでここではプロジェクトのほぼ全体のソースコードを示しておきます.なお,同じような機能を実装する方法は他にも様々考えられるので,一つの例として捉えてください.

public/index.html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>React Comment アプリケーション</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>
src/index.tsximport React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import routes from './routers/routes';
import './index.css';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <BrowserRouter>
      {routes}
    </BrowserRouter>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
src/index.cssbody {
  margin: 0;
  color: #333;
}

.container {
  margin: 20px 20px;
}

h1 {
  margin: 20px 0;
}

article {
  margin: 20px 0;
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 16px;
}

article .title {
  font-size: 1.2em;
  font-weight: bold;
}

article .body {
  font-size: 0.9em;
  line-height: 1.5em;
}

.textInput {
  width: 100%;
  padding: 4px;
  margin: 0 0 8px;
  box-sizing: border-box;
}

.deleteButton {
  text-align: right;
}

.commentsHeader {
  font-size: 0.8em;
}

.infinityScroll {
  text-align: center;
  margin-bottom: 20px;
}
src/routers/routes.tsximport { Route, Routes } from 'react-router-dom';
import CommentListPage from '../components/CommentListPage';
import CommentShowPage from '../components/CommentShowPage';
import CommentEditPage from '../components/CommentEditPage';
import NotFoundPage from '../components/NotFoundPage';

const routes = (
  <Routes>
    <Route path="/" element={<CommentListPage />} />
    <Route path="/show/:commentId" element={<CommentShowPage />} />
    <Route path="/edit/:commentId" element={<CommentEditPage />} />
    <Route path="*" element={<NotFoundPage />} />
  </Routes>
);

export default routes;
src/components/CommentListPage.tsximport React from 'react'
import { useState, useEffect } from 'react';
import axios, { AxiosResponse } from 'axios';
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 [isPrevButtonDisabled, setPrevButtonDisabled] = useState<boolean>(true);
  // const [isNextButtonDisabled, setNextButtonDisabled] = useState<boolean>(false);

  const [results, setResults] = useState<Results>({
    "count":10,
    "next":"http://127.0.0.1:8000/comments/?page=2",
    "previous":null,
    "results":[
      {
        "id":9999,
        "title":"ダミー",
        "body":"ダミーの本文9",
        "updated_at":"2023-11-21T11:20:00"
      },
      {
        "id":10000,
        "title":"ダミーのタイトル",
        "body":"ダミーの本文10",
        "updated_at":"2023-11-21T11:10:00"
      }
    ]
  });

  useEffect(() => {
    const url = "http://127.0.0.1:8000/comments/";
    axios.get<Results>(url)
        // .then((res: AxiosResponse<Results>) => console.log(res.data))
        .then((res: AxiosResponse<Results>) => setResults(res.data))
        .catch(err => console.log(err.message));
  }, []);

  const handleCommentsListChange = (url: string) => {
    axios.get<Results>(url)
        .then((res: AxiosResponse<Results>) => setResults(res.data))
        .catch(err => console.log(err.message));
  }

  const handleDeleteButtonClick = (commentId: number) => {
    if (!window.confirm("削除しますか?")) {
      return;
    }
    const newComments = [...results.results].filter((comment) => {
      return comment.id !== commentId;
    });
    const newResults: Results = {
      "count": results.count - 1, // コメント数は1減らす
      "previous": results.previous,
      "next" : results.next,
      "results": newComments
    };
    setResults(newResults);

    const url = `http://127.0.0.1:8000/comments/${commentId}/`;
    axios
      .delete(url)
      .then(res => {
        if (res.status === 204) {
          handleCommentsListChange('http://127.0.0.1:8000/comments/');
        } else {
          console.log("Delete失敗");
        }
      })
      .catch(err => console.log(err.message));
  };

  const commentItems = results.results.map((comment) => {
    return (
      <Comment
        key={comment.id}
        comment={comment}
        onDelete={handleDeleteButtonClick}
      />
    )
  });

  const handelCreateFormSubmit = async (title: string, body: string):Promise<boolean> => {
    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); // 画面を更新

    let result:boolean = false;
    const url = "http://127.0.0.1:8000/comments/";
    await axios
      .post(url, {
        title: title,
        body: body,
      })
      .then(res => {
        handleCommentsListChange(url);
        result = true;
      })
      .catch(err => {
        setResults(results);
        alert("投稿エラー!未入力または文字数超過です.");
      });
    return result;
  }

  // const handlePrevPageButton = () => {
  //   if (results.previous === null) {
  //     return;
  //   }
  //   axios.get<Results>(results.previous!)
  //       .then((res: AxiosResponse<Results>) => setResults(res.data))
  //       .catch(err => console.log(err.message));
  // };

  // const handleNextPageButton = () => {
  //   if (results.next === null) {
  //     return;
  //   }
  //   axios.get<Results>(results.next!)
  //       .then((res: AxiosResponse<Results>) => setResults(res.data))
  //       .catch(err => console.log(err.message));
  // };

  // useEffect(() => {
  //   setPrevButtonDisabled(results.previous === null);
  //   setNextButtonDisabled(results.next === null);
  // }, [results]);

  const handleInfinityScrollButton = () => {
    axios.get<Results>(results.next!)
      .then((res: AxiosResponse<Results>) => {
        const prevComments = [...results.results];
        const moreComments = [...res.data.results];
        const newResults: Results = {
          count: res.data.count,
          previous: null,
          next : res.data.next,
          results: prevComments.concat(moreComments),
        };
        setResults(newResults);
      })
      .catch(err => console.log(err.message));
  }

  return (
    <div className="container">
      <h1>コメント一覧</h1>

      <div className="commentsHeader">
        <div>コメント総数:{results.count}</div>
      </div>

      {commentItems}

      <div className="infinityScroll">
        <button
          onClick={handleInfinityScrollButton}
        >
          次を読み込む
        </button>
      </div>

      <CreateForm
        onSubmit={handelCreateFormSubmit}
      />
    </div>
  )
}

export default CommentListPage
src/components/Comment.tsximport React from 'react'
import { Link } from 'react-router-dom';

interface CommentProps {
  comment: {
    id: number;
    title: string;
    body: string;
    updated_at: string;
  };
  onDelete: (id: number) => void;
}

const Comment: React.FC<CommentProps> = (props) => {

  const handleClickDeleteButton = () => {
    props.onDelete(props.comment.id);
  };

  return (
    <article>
      <div className="title">
        <Link to={`/show/${props.comment.id}/`}>
          {props.comment.title}
        </Link>
      </div>
      <div className="body">
        {props.comment.body}
      </div>
      <div className="deleteButton">
        <button
          onClick={handleClickDeleteButton}
        >
          削除
        </button>
      </div>
    </article>
  )
}

export default Comment
src/components/CreateForm.tsximport React from 'react'
import { useState, useRef, FormEvent } from 'react';

interface CreateFormProps {
  onSubmit: (title: string, body: string) => Promise<boolean>;
}

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 = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const result = await props.onSubmit(title, body);
    if (result) {
      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
src/components/CommentShowPage.tsximport React from 'react'
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import axios, { AxiosResponse } from 'axios';

interface CommentData {
  id: number;
  title: string;
  body: string;
  updated_at: string;
}

const CommentShowPage: React.FC = () => {
  const {commentId} = useParams<{commentId: string}>();

  const [data, setData] = useState<CommentData | null>(null);
  const url = `http://127.0.0.1:8000/comments/${commentId}/`;

  useEffect(() => {
    axios.get<CommentData>(url)
      .then((res: AxiosResponse<CommentData>) => setData(res.data))
      .catch(err => console.log(err.message));
  }, [url]);

  const navigate = useNavigate();
  const handleBackClick = () => {
    navigate('/');
  };

  if (data === null) {
    return (
      <div className="container">
        <h1>コメント</h1>
        <article>
        </article>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>コメント</h1>
      <article>
        <div>ID: {data.id}</div>
        <div className="title">Title: {data.title}</div>
        <div className="body">Body: {data.body}</div>
        <div className="body">最終更新: {data.updated_at}</div>
        <div>
          <Link to={`/edit/${data.id}/`}>
            コメント情報の編集
          </Link>
        </div>
      </article>
      <button
        onClick={handleBackClick}
      >コメント一覧へ戻る</button>
    </div>
  )
}

export default CommentShowPage
src/componenst/CommentEditPage.tsximport React from 'react'
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import EditForm from './EditForm';

interface CommentData {
  id: number;
  title: string;
  body: string;
}

const CommentEditPage: React.FC = () => {

  const {commentId} = useParams<{commentId: string}>();

  const [data, setData] = useState<CommentData | null>(null);
  const url = `http://127.0.0.1:8000/comments/${commentId}/`;

  useEffect(() => {
    axios.get<CommentData>(url) // レスポンスデータの型付け
      .then(res => setData(res.data))
      .catch(err => console.log(err.message));
  }, [url]);

  const navigate = useNavigate();
  const handleEditFormSubmit = (url: string, title: string, body: string) => {
    axios
      .put(url, {
        title: title,
        body: body,
      })
      .then(res => {
        if (res.status === 200) {
          console.log("PUT成功");
          navigate('/');
        } else {
          console.log("PUT失敗");
        }
      })
      .catch(err => {
        console.log(err.message);
        alert("投稿エラー!未入力または文字数超過です.");
      });
  }

  if (data === null) {
    return (
      <div className="container">
        <h1>コメントの編集</h1>
        <article>
          <p></p>
        </article>
      </div>
    );
  }

  return (
    <div className="container">
      <h1>コメントの編集</h1>
      <EditForm
        commentId={data.id}
        title={data.title}
        body={data.body}
        url={url}
        onSubmit={handleEditFormSubmit}
      />
    </div>
  )
}

export default CommentEditPage
src/components/EditForm.tsximport React from 'react'
import { useState, ChangeEvent, FormEvent } from 'react';

interface EditFormProps {
  commentId: number;
  title: string;
  body: string;
  url: string;
  onSubmit: (url: string, title: string, body: string) => void;
}

const EditForm: React.FC<EditFormProps> = (props) => {
  const [title, setTitle] = useState<string>(props.title);
  const [body, setBody] = useState<string>(props.body);

  const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
    setTitle(event.currentTarget.value);
  };

  const handleBodyChange = (event: ChangeEvent<HTMLInputElement>) => {
    setBody(event.currentTarget.value);
  };

  const formSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    props.onSubmit(props.url, title, body);
  };

  return (
    <>
      <form onSubmit={formSubmit}>
        <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>
    </>
  )
}

export default EditForm
src/components/NotFoundPage.tsximport React from 'react'

const NotFoundPage = () => {
  return (
    <div className="container">
      <h1>404 Not Found</h1>
      <p>指定したページは見つかりませんでした</p>
    </div>
  )
}

export default NotFoundPage

目次に戻る