ReactとFirebaseで掲示板作ってみた。ログインなしで誰でも書き込めます:その1

ReactとFirebaseで掲示板作ってみた。ログインなしで誰でも書き込めます:その1

プログラマーであれば誰でも一度は掲示板なるものを作ってみたいと思うはず。

React勉強中ということもあり、試しにFirebaseと連携して掲示板を作ってみることにした。もし、同士の方がいれば参考にしてほしい。

コードを少しずつ書いて、丁寧に説明するつもりはない。完成形のコードを貼り付けて、参考になりそうなところだけ見て欲しい。適宜解説は入れている。

デモはこちらから。

React×Firebase掲示板作成。大まかな設計

関数コンポーネントの使い分けは適宜、自由に設定してもらって構わない。要はformボタンが押されたらfirebaseのfirestore(データベース)に接続して、そのデータを取得したり格納したりするだけ。

Reduxも多少は使っているが、そこまで複雑なことはしていない。

実際のコーディング内容

それではさらっとコードを見ていこう。

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { Provider } from "react-redux";
import { store } from "./app/store";


ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById("root")
);

reportWebVitals();

index.jsは外せない。Providerというタグを使っているが、これはRedux用に設定したもの。storeデータをグローバルにしようするためにタグをつけた。とりあえずAppタグにstoreデータを使いたいだけ。という意味。

それではAppコンポーネントの中身は何かみてみよう。

import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import Header from "./components/Header";
import Main from "./components/Main";
import "./style.css";

function App() {
  return (
    <Router>
      <div className="App">
        <Header />
        <Switch>
          <Route exact path="/">
            <Main />
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

export default App;

ルーティング設定をしてはいるが、今回はメインページだけで完結するから設置しなくても良い。後にページネーションも実装するが、ページネーションではルーティングせず、表示するデータを変更させるだけにしている。すべては非同期で処理する。

Main.jsの中身は何か。

import React, { useRef, useState } from "react";
import ThreadArea from "./ThreadArea";
import TopButtons from "./buttons/TopButtons";
import NewPostThread from "./NewPostThread";
import ReactPaginate from "react-paginate";
import { db } from "../firebase";
import { useCollection } from "react-firebase-hooks/firestore";


function Main() {
  const perPage = 5;


  const threadAddRef = useRef(null);
  const newThreadAddRef = useRef(null);


  const [threadInfo, loading] = useCollection(
    db.collection("threads").orderBy("timestamp", "desc")
  );


  const [pagenateInfoList, setPagenateInforList] = useState({
    offset: 0, //始まりの位置
    perPage: perPage, //1ページに表示するスレッド数
  });
  const handlePageClick = (data) => {
    /* slice a list five thread and update offset */
    let pageNumber = data["selected"]; //2を押したら1が返る。
    //始まりの位置が変更されるだけ
    setPagenateInforList({
      offset: pageNumber * perPage, //2番のリンクをクリックしたら1*5=5になる。
      perPage: perPage,
    });
    //pageのトップに自動でスクロールする。
    threadAddRef?.current?.scrollIntoView({
      behavior: "smooth",
    });
  };


  var Spinner = require("react-spinkit");


  if (loading) {
    return (
      <div className="loadingArea">
        <div className="loadingInnerArea">
          <div className="loadingText">Now Loading...</div>
          <Spinner name="ball-spin-fade-loader" color="purple" fadeIn="none" />
        </div>
      </div>
    );
  }


  return (
    <div>
      <div ref={threadAddRef} style={{ paddingTop: 10 }}></div>
      <TopButtons newThreadAddRef={newThreadAddRef} />
      <ReactPaginate
        pageCount={Math.ceil(threadInfo?.size / perPage)}
        marginPagesDisplayed={2}
        pageRangeDisplayed={3}
        onPageChange={handlePageClick}
        containerClassName="paginateContainer"
        pageClassName="pageItem"
        pageLinkClassName="pageLink"
        activeClassName={"active"}
        previousLabel={"<<"}
        nextLabel={">>"}
        previousClassName="pageItem" // '<'の親要素(li)のクラス名
        nextClassName="pageItem"
        disabledClassName="disabled" //先頭or末尾に行ったときにそれ以上戻れ(進め)なくするためのクラス
        breakLabel={"..."}
        breakClassName="pageItem" // 上記の「…」のクラス名
        breakLinkClassName="pageIink" // 「…」の中のリンクにつけるクラス
      />
      <ThreadArea
        threadInfo={threadInfo}
        offset={pagenateInfoList.offset}
        perPage={pagenateInfoList.perPage}
      />
      {/* paginationArea */}
      <ReactPaginate
        pageCount={Math.ceil(threadInfo?.size / perPage)}
        marginPagesDisplayed={2}
        pageRangeDisplayed={3}
        onPageChange={handlePageClick}
        containerClassName="paginateContainer"
        pageClassName="pageItem"
        pageLinkClassName="pageLink"
        activeClassName={"active"}
        previousLabel={"<<"}
        nextLabel={">>"}
        previousClassName="pageItem" // '<'の親要素(li)のクラス名
        nextClassName="pageItem"
        disabledClassName="disabled" //先頭or末尾に行ったときにそれ以上戻れ(進め)なくするためのクラス
        breakLabel={"..."}
        breakClassName="pageItem" // 上記の「…」のクラス名
        breakLinkClassName="pageIink" // 「…」の中のリンクにつけるクラス
      />
      <NewPostThread
        threadAddRef={threadAddRef}
        setPagenateInforList={setPagenateInforList}
      />
      <div style={{ paddingBottom: 10 }} ref={newThreadAddRef}></div>
    </div>
  );
}

exportdefaultMain;

なるほどMainページともあれば膨大な量のコードになるのは致し方ない。firestoreに保存してあるデータをuseStateとして格納し、データ数獲得のために利用している。firebaseとの連携、接続に関してはドキュメントを読めばいい。詳しくfirebase公式にチュートリアルが書かれている。

あとは基本となるHTMLレイアウト。そこから関数処理。特にページネーションはライブラリを使って実装している。ページネーションさせるためのoffsetやらperPage設定は役に立つだろう。1画面表示にどれだけスレッドを表示させるか。それさえ分かれば簡単に計算ができる。

ちなみに、スレッドを投稿した際は自動でホームページトップ遷移させるJsも実装している。

TopButtonコンポーネントは以下の通り。

import React from "react";
import ContactButton from "./ContactButton";
import NewThreadButton from "./NewThreadButton";

function TopButtons({ newThreadAddRef }) {
  return (
    <div className="topButtonsArea">
      <NewThreadButton newThreadAddRef={newThreadAddRef}>
        新規スレッド
      </NewThreadButton>
      <ContactButton>連絡</ContactButton>
    </div>
  );
}

export default TopButtons;

NewTheradボタンとContactボタンでコンポーネントで分けているが、無理に分ける必要はない。ここら辺はお好みでいいだろう。好きにレイアウトすればいい。

続いてはTheadArea.jsxの中身を覗いてみる。

import React from "react";
import Thread from "./Thread";


function ThreadArea({ threadInfo, offset, perPage }) {
  return (
    <div className="threadArea">
      {threadInfo?.docs.slice(offset, offset + perPage).map((thread) => {
        const { name, threadFirstComment, title, timestamp } = thread.data();
        return (
          <Thread
            key={thread.id}
            id={thread.id}
            name={name}
            threadFirstComment={threadFirstComment}
            title={title}
            timestamp={timestamp}
          />
        );
      })}
    </div>
  );
}

export default ThreadArea;

ページネーションで表示するデータを制限し、うまい具合にmap関数でその分だけデータを取り出している。これは我ながらうまくやってのけたと思う。useStateをつかった配列からmapで情報を取り出すことは読者もやったことがあるだろう。イメージはその配列をfirestoreからとってきたデータに置き換えるだけ。それをただmapをつかって取り出して表示しているだけである。

肝となるThead.jsxを見てみよう。

import React, { useRef } from "react";
import { useState } from "react";
import { db } from "../firebase";
import firebase from "@firebase/app-compat";
import { useDispatch, useSelector } from "react-redux";
import { selectThread, selectThreadId } from "../features/appSlice";
import { useCollection } from "react-firebase-hooks/firestore";
// import { collection, addDoc, setDoc, doc } from "firebase/firestore";


function Thread({ id, name, threadFirstComment, title, timestamp }) {
  const [inputName, setInputName] = useState("");
  const [inputTextArea, setInputTextArea] = useState("");
  const [replyToName, setReplyToName] = useState("");
  const [nameErrors, setNameErrors] = useState([]);
  const [textAreaErrors, setTextAreaErrors] = useState([]);
  const replyFormRef = useRef(null);


  const dispatch = useDispatch();
  const threadId = useSelector(selectThreadId); //フォーカスしたときにしか呼ばれないよね。
  const [replyInfo] = useCollection(
    id &&
      db
        .collection("threads")
        .doc(id)
        .collection("reply")
        .orderBy("timestamp", "asc")
  );


  const handleChange = (e) => {
    setInputName(e.target.value);
  };


  const handleTextAreaChange = (e) => {
    setInputTextArea(e.target.value);
  };


  const formVailed = () => {
    if (inputName.length === 0 && inputTextArea.length !== 0) {
      setNameErrors({
        errorMessage: "※未入力エラーです。",
      });
      setTextAreaErrors([]);
    } else if (inputName.length !== 0 && inputTextArea.length === 0) {
      setTextAreaErrors({
        errorMessage: "※未入力エラーです。",
      });
      setNameErrors([]);
    } else if (inputName.length === 0 && inputTextArea.length === 0) {
      setNameErrors({
        errorMessage: "※未入力エラーです。",
      });
      setTextAreaErrors({
        errorMessage: "※未入力エラーです。",
      });
      return false;
    } else if (inputName.length !== 0 && inputTextArea.length !== 0) {
      setNameErrors([]);
      setTextAreaErrors([]);
      return true;
    }
    return false;
  };


  const handleSubmit = (e) => {
    e.preventDefault();
    if (formVailed()) {
      /* データベースに返信したデータを送る */
      /* 返信先を指定しているときはreplyToName: >>>宛名とともに保存する。 */
      if (replyToName.length > 0) {
        console.log(replyToName);
        threadId &&
          db
            .collection("threads")
            .doc(threadId)
            .collection("reply")
            .add({
              name: inputName,
              replyAddress: ">>" + replyToName,
              replyComment: inputTextArea,
              timestamp: firebase.firestore.FieldValue.serverTimestamp(),
            });
        setInputName("");
        setInputTextArea("");
        setReplyToName("");
        return;
      }
      threadId &&
        db.collection("threads").doc(threadId).collection("reply").add({
          name: inputName,
          replyComment: inputTextArea,
          timestamp: firebase.firestore.FieldValue.serverTimestamp(),
        });
      setInputName("");
      setInputTextArea("");
      setReplyToName("");
    }
  };


  const handleFocus = () => {
    /* どのthreadのなのかを指定 */
    if (id) {
      dispatch(
        selectThread({
          threadId: id,
        })
      );
    }
  };


  const handleReplyButton = (name) => {
    /* 返信先宛名を設定 */
    setReplyToName(name);
    /* 自動で返信欄へスクロール */
    replyFormRef?.current?.scrollIntoView({
      behavior: "smooth",
    });
  };


  const replyCancel = () => {
    /* 返信を解除 */
    console.log("a");
    setReplyToName("");
  };


  return (
    <div>
      <div className="thread">
        <strong>
          <span id="threadnumber">お題:</span>
          {title}
        </strong>
        <div className="threadComment">
          <p id="username">
            1 名前: <b> {name}</b>
            <span className="threadInfo">
              <span className="dateText">
                {new Date(timestamp?.toDate()).toLocaleString()}
              </span>
              <button
                className="replyButton"
                onClick={() => handleReplyButton(name)}
              >
                [返信]
              </button>
            </span>
          </p>
          <p id="threadContentArea">
            <span id="threadContent">{threadFirstComment}</span>
          </p>
          {/* threadIdによって表示させる内容を変える必要がある。 */}
          {/* threadId4ならthreadId4のreplyInfoをmapで繰り返し表示させる */}
          {id &&
            replyInfo?.docs.map((reply, index) => {
              const { name, replyComment, timestamp, replyAddress } =
                reply.data();
              return (
                <div key={reply.id}>
                  <p id="username">
                    {index + 2} 名前: <b> {name}</b>
                    <span className="threadInfo">
                      <span className="dateText">
                        {new Date(timestamp?.toDate()).toLocaleString()}
                      </span>
                      <button
                        className="replyButton"
                        onClick={() => handleReplyButton(name)}
                      >
                        [返信]
                      </button>
                    </span>
                  </p>
                  <div id="threadContentArea" ref={replyFormRef}>
                    <span id="threadContent">
                      {/* 送信ボタンを押したかつreplyToNameに名前があれば、改行してリプライする */}
                      {replyAddress && (
                        <div
                          className="replyAddress"
                          style={{ marginBottom: 2 }}
                        >
                          {replyAddress}
                        </div>
                      )}
                      {replyComment}
                    </span>
                  </div>
                </div>
              );
            })}
        </div>
        {/* 返信用フォーム */}
        <form onSubmit={handleSubmit}>
          <table className="replyTable">
            <tbody>
              <tr>
                <th>おなまえ</th>
                <td>
                  <input
                    onChange={handleChange}
                    value={inputName}
                    type="name"
                    name="name"
                    maxLength="12"
                    className="nameInput"
                    onFocus={handleFocus}
                  />
                  {nameErrors && (
                    <span id="errorMessage">{nameErrors.errorMessage}</span>
                  )}
                </td>
              </tr>
              <tr>
                <th colSpan="2">
                  このスレッドに書き込む
                  <span className="replyToName">
                    {replyToName ? "(To:" + replyToName + "さんへ)" : ""}
                  </span>
                  <br />
                  <textarea
                    placeholder={replyToName && `>>${replyToName}さんへの返信`}
                    onChange={handleTextAreaChange}
                    name="comment"
                    className="textArea"
                    value={inputTextArea}
                  ></textarea>
                  {textAreaErrors && (
                    <span id="errorMessage">{textAreaErrors.errorMessage}</span>
                  )}
                  <div className="replyButtons">
                    <button className="submitButton">投稿</button>
                    <button
                      type="button"
                      className="replyCancel"
                      onClick={replyCancel}
                    >
                      返信解除
                    </button>
                  </div>
                </th>
              </tr>
            </tbody>
          </table>
        </form>
      </div>
    </div>
  );
}


export default Thread;

theadIdによってスレッド毎に表示させるデータを変更させる。そのためにReduxでthreadIDをグローバルに共有できるように設定しているのである。useRefやらuseDispatch,useSelectorなど難しいHooksが登場しているが、すべてはReduxのために使用している。

この掲示板は返信機能も実装している。これを作成したのが1か月前だから詳しいことは覚えていない。コメントアウトの部分を読んで理解してほしい。正直ここのコンポーネントはリファクタの余地が往々にしてある。コード量が長すぎるから各自リファクタしてほしい。

次回はfirebaseの設定コーディングを見てみることにする。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です