🗂

ReactとDjangoで登録フォームとモーダル画面作ってみた

2024/08/21に公開

元美容師のDjangoポートフォリオリニューアル日記 Part.4:コンポーネント分割とモーダルフォーム編(material-UIも少しだけ)

こんにちは、やぎです!

前回の記事では、DjangoとReactを連携させ、データベースに登録されているサロンデータの取得と表示を実装しました。

今回は、フロントエンドでサロンの登録機能を追加していきます!

今回の実装内容

1.サロン登録機能の追加(単一ファイルで実装)
まずは前回作成したapp.jsに追加をしてサロン登録機能を作成します。

2.コンポーネントの分割
単一ファイルで作成していた機能を、部品ごとに分けてコンポーネント思考で作り直します。

3.Material-UIの導入
見た目を整える為にスタイルをあてていきます。今回はMaterial-UIを使用してみます。

4.モーダルでのフォーム表示
登録フォームをモーダル画面で表示してみます。

それでは実装開始!

1. サロン登録機能の追加(単一ファイルで実装)

まず、既存のApp.js にサロン登録機能を追加します。
まずはファイル全体を記載して、そのあと追加した関数について簡単に説明します!

import React, { useState, useEffect } from 'react';
import axios from 'axios';

function App() {
  const [salons, setSalons] = useState([]);
  const [newSalon, setNewSalon] = useState({
    name: '', address: '', description: '', phone_number: '', email: ''
  });

  useEffect(() => {
    fetchSalons();
  }, []);

  const fetchSalons = () => {
    axios.get('http://localhost:8000/api/salons/')
      .then(response => {
        setSalons(response.data);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  const handleInputChange = (e) => {
    setNewSalon({ ...newSalon, [e.target.name]: e.target.value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    axios.post('http://localhost:8000/api/salons/', newSalon)
      .then(response => {
        setSalons([...salons, response.data]);
        setNewSalon({ name: '', address: '', description: '', phone_number: '', email: '' });
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  return (
    <div>
      <h2>サロン登録</h2>
      <form onSubmit={handleSubmit}>
        <input name="name" value={newSalon.name} onChange={handleInputChange} placeholder="サロン名" />
        <input name="address" value={newSalon.address} onChange={handleInputChange} placeholder="住所" />
        <textarea name="description" value={newSalon.description} onChange={handleInputChange} placeholder="説明" />
        <input name="phone_number" value={newSalon.phone_number} onChange={handleInputChange} placeholder="電話番号" />
        <input name="email" value={newSalon.email} onChange={handleInputChange} placeholder="メールアドレス" />
        <button type="submit">登録</button>
      </form>

      <h2>サロン一覧</h2>
      {/* 既存のサロン一覧表示コード */}
    </div>
  );
}

export default App;

追加した関数の説明

handleInputChange:

const handleInputChange = (e) => {
    setNewSalon({ ...newSalon, [e.target.name]: e.target.value });
  };

この関数はフォームの入力値が変更されたときに呼び出され、newSalon の状態を更新します。
入力(event)されたオブジェクトを引数eとして、入力フィールドの名前と値を取得し、その名前に対応する newSalon のプロパティを更新します。
ポイント: スプレッド構文 ...newSalon を使用して、既存の値を保持しつつ、変更されたフィールドのみを更新します。

handleSubmit:

const handleSubmit = (e) => {
    e.preventDefault();
    axios.post('http://localhost:8000/api/salons/', newSalon)
      .then(response => {
        setSalons([...salons, response.data]);
        setNewSalon({ name: '', address: '', description: '', phone_number: '', email: '' });
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

フォームが送信されたときに呼び出され、新しいサロンデータをDjangoで作成したAPIと連携してデータベースに登録します。
この際に、デフォルトのフォーム送信動作を防ぐため、e.preventDefault() を呼び出します。
axios.post を使用して、サーバーに新しいサロンデータを送信します。
成功した場合、新しいサロンを salons 配列に追加し、newSalon の状態をリセットします。
.catchでエラーが発生した場合、コンソールにエラーを表示するようにしました。

サーバーを起動してみます!

無事に登録フォームが表示されました!
ではサロンが登録できるか試してみます!

サロン情報を入力して登録をクリック

無事にサロンリストに登録したサロンが追加されました!!
続いては単一ファイルで作成したこの画面をコンポーネント分割して同じ画面を作成してみます!

2. コンポーネントの分割

次に、コードを整理しやすくするためにコンポーネントを分割します。
コンポーネント分割はReactにおいて非常に重要な実装方法です。
先ほどのように単一ファイルでも実装することは可能ですが、各機能ごとに分けたほうが改修作業が格段にやりやすくなります。
今回は以下のコンポーネントに分割します。

SalonList.js:サロン一覧を表示する機能
SalonForm.js:登録フォーム機能
既存のApp.js:コンポーネントをimport、その他のメイン機能

src/componentsディレクトリを作成してその中にそれぞれのファイルを作成して、App.jsに記載されていた機能を移植していきます。

SalonList.js:

import React from 'react';

function SalonList({ salons }) {
  return (
    <div>
      <h2>サロン一覧</h2>
      {salons.length === 0 ? (
        <p>サロンは登録されていません</p>
      ) : (
        <ul>
          {salons.map(salon => (
            <li key={salon.id}>
              <p>サロン名:{salon.name}</p>
              <p>住所:{salon.address}</p>
              <p>電話番号:{salon.phone_number}</p>
              <p>メールアドレス:{salon.email}</p>
              <p>紹介文:{salon.description}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default SalonList;

SalonForm.js:

import React, { useState } from 'react';

function SalonForm({ onSalonSubmit }) {
  const [newSalon, setNewSalon] = useState({
    name: '', address: '', description: '', phone_number: '', email: ''
  });

  const handleInputChange = (e) => {
    setNewSalon({ ...newSalon, [e.target.name]: e.target.value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    onSalonSubmit(newSalon);
    setNewSalon({ name: '', address: '', description: '', phone_number: '', email: '' });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={newSalon.name} onChange={handleInputChange} placeholder="サロン名" />
      <input name="address" value={newSalon.address} onChange={handleInputChange} placeholder="住所" />
      <textarea name="description" value={newSalon.description} onChange={handleInputChange} placeholder="説明" />
      <input name="phone_number" value={newSalon.phone_number} onChange={handleInputChange} placeholder="電話番号" />
      <input name="email" value={newSalon.email} onChange={handleInputChange} placeholder="メールアドレス" />
      <button type="submit">登録</button>
    </form>
  );
}

export default SalonForm;

コンポーネント分割後の App.js

それぞれの機能を各コンポーネントファイルに移植した後は、既存のApp.jsではメイン機能のみを担うように変更します。また作成したコンポーネントをimportして使用できるようにします。

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import SalonList from './SalonList';
import SalonForm from './SalonForm';

function App() {
  const [salons, setSalons] = useState([]);

  useEffect(() => {
    fetchSalons();
  }, []);

  const fetchSalons = () => {
    axios.get('http://localhost:8000/api/salons/')
      .then(response => {
        setSalons(response.data);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  const handleSalonSubmit = (newSalon) => {
    axios.post('http://localhost:8000/api/salons/', newSalon)
      .then(response => {
        setSalons([...salons, response.data]);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  return (
    <div>
      <h1>SalonLink</h1>
      <SalonForm onSalonSubmit={handleSalonSubmit} />
      <SalonList salons={salons} />
    </div>
  );
}

export default App;

コンポーネント分割時のポイント
handleInputChange と handleSubmit これらの関数はフォームの動作に直接関連しているためSalonForm コンポーネントに移動しました。

fetchSalonsはアプリケーション全体でサロンデータを管理するためApp.jsに残しました。

新しく handleSalonSubmit 関数をApp.jsに作成し、この関数を SalonForm にpropsとして渡しています。これにより、子コンポーネント(SalonForm.js)から親コンポーネント(App.js)の状態を更新できるようにします。

ではコンポーネント分割した状態でもサロン登録とサロンリストが機能するかテストします!

フォームとサロンリストはしっかりと表示されました!
新しいサロンBを登録してみます!

無事にサロンBが登録されサロンリストに追加されました!
「サロンが登録されました」のアラートを表示してもいいかもしれないですねw
続いてはちょっとデザインが寂しいので少し見た目を整えていきます!

3. Material-UIの導入

UIを改善するためにMaterial-UIを導入しました。
今まで個人開発ではBootStrapを使用することが多かったのですが今回初めてMaterial-UIを導入してみました!
タグ名を変更するだけである程度のデザインが整うでとても使いやすかったです。

公式サイト
https://mui.com/material-ui/

実際にはこの後Modal.jsを実装するので順番が前後してしまいますが、先にMaterial-UIで使用したタグについて軽く説明します。

まず以下のコマンドでMaterial-UIをインストールします。

npm install @mui/material @emotion/react @emotion/styled

使用したタグについてです。
今回は基本的なタグしか使用していませんが、公式サイトに様々なUIコンポーネントが記載されていますので是非いろいろ試してみて下さい

Button:サロン登録ボタンに使用しました。
variant="contained" を設定して、色付きのボタンを簡単に表示しています。

<Button variant="contained" onClick={() => setIsModalOpen(true)}>
  サロン登録はこちら
</Button>

Typography:タイトルに使用しました。
テキストの見栄えを簡単に整えるために使用されます。手軽に統一感のあるデザインを作成できます。
タイトルには variant="h4" を指定し適度な大きさにしました。

<Typography variant="h4">SalonLink</Typography>

Container:アプリ全体に使用しました。
ページのレイアウトやコンテンツの中央寄せを簡単に行えるコンポーネントです。これを使用することで、UIの余白やレイアウトが自然に整い、見た目のバランスが向上します。

<Container>
  <Typography variant="h4">SalonLink</Typography>
  {/* 他のコンテンツ */}
</Container>

Dialog:モーダル画面の開閉に使用しました。
このあと登場するモーダル画面の制御で使用します。
開閉の状態を isOpen on Closeで制御でき、シンプルにモーダルを表示できます。

<Dialog open={isOpen} onClose={onClose}>
  <DialogContent>
    <Button onClick={onClose}>閉じる</Button>
    {children}
  </DialogContent>
</Dialog>

DialogContent:モーダル内のコンテンツに使用しました。
モーダル内のコンテンツエリアを定義するために使用します。モーダルに表示される登録フォームを DialogContent 内に配置しており、モーダルを開いたときにフォームが表示されるようにしています。

以上です。今回はかなりシンプルにしましたが、最終的にはよりデザインにもこだわったスタイルをあてたいと思います。また、Material-UIはデフォルトでレスポンシブ対応されているんです!
特別な設定を行わなくてもモバイル、タブレット、デスクトップの画面サイズに自動で適応してくれるのはありがたい...
ただ細かい設定をしたい場合はもちろん自身で追加する必要があります。

是非皆さんも一度使用してみて下さい。

実際の画面はこの後実装するModal.jsを作成後に添付します。

4. モーダルでのフォーム表示

今回作成したフォーム機能ですが、サロンリストと同じ画面にあると少しデザイン的に微妙です。Material-UIのキャッチアップをしたところ、モーダル画面も簡単に実装できそうだったので登録フォームをモーダル画面で表示できるように実装していきます。

まずはcomponents/Modal.jsを作成して以下のコードを書きます

Modal.js:

import React from 'react';
import { Dialog, DialogContent, Button } from '@mui/material';

function Modal({ isOpen, onClose, children }) {
  return (
    <Dialog open={isOpen} onClose={onClose}>
      <DialogContent>
        <Button onClick={onClose}>×</Button>
        {children}
      </DialogContent>
    </Dialog>
  );
}

export default Modal;

このコンポーネントではフォーム以外にもモーダル表示ができるように、表示内容は {children}として受け取ることができます。
またこの後記載するApp.jsからisOpenを受け取ることで、<Dialog>タグ内のコンテンツをモーダルで表示することができるようになります。
×ボタンにonCloseを渡すことでモーダルを閉じます。

モーダルを利用した App.js

続いてApp.jsに「サロン登録はこちら」ボタンを作成し、ボタンクリックに応じてモーダルを表示するためのコードを追記します。

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Button, Container, Typography } from '@mui/material';
import SalonList from './components/SalonList';
import SalonForm from './components/SalonForm';
import Modal from './components/Modal';

function App() {
  const [salons, setSalons] = useState([]);
  const [isModalOpen, setIsModalOpen] = useState(false);

  useEffect(() => {
    fetchSalons();
  }, []);

  const fetchSalons = () => {
    axios.get('http://localhost:8000/api/salons/')
      .then(response => {
        setSalons(response.data);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  const handleSalonSubmit = (newSalon) => {
    axios.post('http://localhost:8000/api/salons/', newSalon)
      .then(response => {
        setSalons([...salons, response.data]);
        setIsModalOpen(false);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  return (
    <Container>
      <Typography variant="h4">SalonLink</Typography>
      <Button variant="contained" onClick={() => setIsModalOpen(true)}>
        サロン登録はこちら
      </Button>
      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
        <SalonForm onSalonSubmit={handleSalonSubmit} />
      </Modal>
      <SalonList salons={salons} />
    </Container>
  );
}

export default App;

上記のコードではモーダルの開閉を監視するisModalOpenのuseStateを追加しています。

const [isModalOpen, setIsModalOpen] = useState(false);

「サロン登録はこちら」ボタンにonClick関数を付与して、クリックするとsetIsModalOpenをtrueに変更されます。
ここで<Modal>タグ内のisOpenが起動して、先ほど作成したModal.jsが表示されます。

<Button variant="contained" onClick={() => setIsModalOpen(true)}>
        サロン登録はこちら
</Button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<SalonForm onSalonSubmit={handleSalonSubmit} />
</Modal>

またサロン登録するためのhandleSalonSubmit成功時にsetIsModalOpen(false)を追加することで、サロン登録と同時にモーダルを閉じるようにしています。

const handleSalonSubmit = (newSalon) => {
    axios.post('http://localhost:8000/api/salons/', newSalon)
      .then(response => {
        setSalons([...salons, response.data]);
        setIsModalOpen(false);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });

では実際の画面を見てみましょう!

最初よりかは見た目もマシになっているのではないでしょうか笑
フォームが消えて、ボタンが表示されています。
さてモーダルでフォームが表示されるか確かめます!

無事にモーダルでフォームが表示されました!
サロン情報を入力して登録してみます!

登録後...

登録したサロンCがサロンリストに追加されました!!!!

以上で今回の実装は終了です!
最後に今回の記事のまとめを記載して終わりにします。

まとめ

今回の実装では単一ファイルで作成した機能を、コンポーネントを分割してみました。
コンポーネントをどの程度分割するか悩みますが、なるべく細かく分けた方が長い目で見た際に、改修しやすいかなと思います。
また、Material-UIを導入してUIを導入して最低限のデザインも追加しました。最終的にはよりこだわったデザインでユーザビリティにもこだわりたいと思います。

今回の過程を通じて、Reactアプリケーションの構造化と、UIの段階的な改善方法を学ぶことができました!
次回の予定ですが以下の内容で記事を書こうと考えています!
・サロン情報の編集機能
・サロン情報の削除機能
・記事ボリュームによっては画像登録も!

今回少し記事が長くなってしまったのと、それに伴い投稿までの期間が空いてしまいました...
次回からはよりコンパクトで読みやすい記事を意識したいと思います。
お読みいただきありがとうございました。

それでは次回の実装をお楽しみに!!

やぎ

Discussion