自動化無しに生活無し

WEB開発関係を中心に備忘録をまとめています

DRF+ReactのSPAでCRUD簡易掲示板をつくる

thumbnail

開発要件

  • DRF+ReactでSPA
  • react-router-domでページURLの構築
  • 簡易掲示板のCRUD機能
  • カテゴリ指定・追加機能
  • リプライ投稿機能
  • CSRF検証の挙動を確認するため、あえてJWT認証はオミット
  • CRAではなくViteを使用

以前のものは、1対多には対応しておらず、CRAを利用していた。またページもない。

更に、axiosがコールバック地獄になっているので、そちらも修正した。

使用ライブラリ

サーバーサイド

以下、requirements.txt

asgiref==3.8.1
Django==5.1.4
django-cors-headers==4.6.0
djangorestframework==3.15.2
sqlparse==0.5.3
typing_extensions==4.12.2

DRFに加えReactサーバーからのリクエストを受け付けるよう、django-cors-headersを使っている。

これで、DRF+ReactのSPAを作るには、必要最小限のライブラリと言える。

フロントサイド

以下、package.json

{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@fortawesome/fontawesome-free": "^6.7.2",
    "axios": "^1.7.9",
    "bootstrap": "^5.3.3",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^7.1.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.17.0",
    "@types/react": "^18.3.18",
    "@types/react-dom": "^18.3.5",
    "@vitejs/plugin-react": "^4.3.4",
    "eslint": "^9.17.0",
    "eslint-plugin-react": "^7.37.2",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.16",
    "globals": "^15.14.0",
    "vite": "^6.0.5"
  }
}

装飾のためのfontawesomeとbootstrap。

定番のaxiosに、react-router-dom でページURLを構築している。

今回は開発が停止されたCRAに変わり、Viteを使用した。

npm install @fortawesome/fontawesome-free
npm install bootstrap
npm install axios 
npm install react-router-dom

サーバーサイドのコード解説

models.py

from django.db import models
from django.utils import timezone

class Category(models.Model):
    name        = models.CharField(verbose_name="カテゴリ名",max_length=100)


class Topic(models.Model):
    category    = models.ForeignKey(Category,verbose_name="カテゴリ",on_delete=models.CASCADE)

    created_at  = models.DateTimeField(verbose_name="投稿日時",default=timezone.now)
    comment     = models.CharField(verbose_name="コメント",max_length=2000)
    

class Reply(models.Model):
    topic       = models.ForeignKey(Topic,verbose_name="カテゴリ",on_delete=models.CASCADE)

    created_at  = models.DateTimeField(verbose_name="投稿日時",default=timezone.now)
    comment     = models.CharField(verbose_name="コメント",max_length=2000)

オーソドックスなモデル。

投稿日時はauto_now などのフィールドオプションで自動入力できるが、今回は管理サイトからの投稿日時の編集が許されるように、あえて使わず、default=timezone.nowとした。

serializers.py

以前のモデルからadmin.pyを生成するツールを、Serializer用に修正し、自動生成した。

# == This code was created by https://noauto-nolife.com/post/django-auto-create-models-forms-serializers/== #

from rest_framework import serializers
from .models import Category,Topic,Reply

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model	= Category
        fields	= ("id", "name")

class TopicSerializer(serializers.ModelSerializer):

    created_at  = serializers.DateTimeField(format="%Y年%m月%d日 %H時%M分%S秒",required=False)

    class Meta:
        model	= Topic
        fields	= ("id", "category", "created_at", "comment")


class ReplySerializer(serializers.ModelSerializer):

    created_at  = serializers.DateTimeField(format="%Y年%m月%d日 %H時%M分%S秒",required=False)

    class Meta:
        model	= Reply
        fields	= ("id", "topic", "created_at", "comment")

djangoのDateTimeFieldをシリアライズしてレスポンスする時、日付のフォーマットは、2025-01-05T17:52:38.881338+09:00と、タイムゾーンありの文字列で返されてしまう。

そこでまず、フロントサイドでのフォーマットの編集も考えた。

Intl ではタイムゾーンが考慮されず、luxonなどの外部ライブラリに頼るしかないため、サーバー側でフォーマットを修正するようにした。

created_at  = serializers.DateTimeField(format="%Y年%m月%d日 %H時%M分%S秒",required=False)

これで入力時は値を受け付けず、表示時には年月日のフォーマットで表示される。

また、今回は下記記事のようにカテゴリをネストしないようにした。

1対多のモデル構造で、ネストしたserializers.pyを作る

ネストしなくても、カテゴリのデータを使ってフロント側で照合する。

views.py

普通の、ModelViewSet。こちらも生成ツールを時前で用意して、時短した。

from django.shortcuts import render

# Create your views here.
# == This code was created by https://noauto-nolife.com/post/django-auto-create-views/ == #

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated

from .models import Category,Topic,Reply
from .serializers import CategorySerializer,TopicSerializer,ReplySerializer

class CategoryView(viewsets.ModelViewSet):
    #permission_classes  = [IsAuthenticated]
    serializer_class    = CategorySerializer
    queryset            = Category.objects.all()


class TopicView(viewsets.ModelViewSet):
    #permission_classes  = [IsAuthenticated]
    serializer_class    = TopicSerializer
    queryset            = Topic.objects.all()


class ReplyView(viewsets.ModelViewSet):
    #permission_classes  = [IsAuthenticated]
    serializer_class    = ReplySerializer
    queryset            = Reply.objects.all()

    def get_queryset(self):
        topic_id    = self.request.query_params.get('id')

        try:
            if topic_id:
                return Reply.objects.filter(topic=topic_id)
        except Exception as e:
            print(e)

        # idの指定なしの場合はNoneを返す
        return Reply.objects.none()

今回、CSRFの挙動を確認するため、JWT認証は行っていない。

ReplyViewはidを指定してTopicに紐づくReplyのみ返すようにしている。パラメータの指定がない場合は、何も返さない。

ModelViewSetでは、基本CSRF検証は免除されている。だが、JWT認証をしない場合、CSRF検証はされる。

詳細は、DRFはいつCSRF検証をするのか? を確認。

バリデーションエラーのとき、400エラーが帰ってくる

ModelViewSetでは、バリデーションエラーの時、400エラーが帰ってくる。

DRFで400 Bad Request エラーが出る時、Serializerを確認する を確認。

urls.py

CSRFトークンを返却するビューを用意した。

"""
URL configuration for config project.

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/5.1/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path,include


from rest_framework import routers
from bbs import views

router = routers.DefaultRouter()
router.register(r"topics", views.TopicView, "topic")
router.register(r"categories", views.CategoryView, "category")
router.register(r"replies", views.ReplyView, "reply")


from django.http import JsonResponse
from django.middleware.csrf import get_token

def csrf_token(request):
    return JsonResponse({"csrfToken": get_token(request)})

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include(router.urls)),

    path("api/csrf-token/", csrf_token, name="csrf_token"),
]

今回、CSRF検証の動作確認のため、CSRFトークンを返却するビューを用意した。

router.register(r"topics", views.TopicView, "topic")
router.register(r"categories", views.CategoryView, "category")
router.register(r"replies", views.ReplyView, "reply")

RESTの原則に従い、URLは全て名詞の複数形で対応している。

https://ja.wikipedia.org/wiki/Representational_State_Transfer

settings.py

"""
Django settings for config project.

Generated by 'django-admin startproject' using Django 5.1.4.

For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""

from pathlib import Path

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-$@5l03*vzlmr3-vfkygfkxwqevb+umx@trwp762_%v(e$plfxq'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [

    "bbs.apps.BbsConfig",
    'corsheaders',
    'rest_framework',    

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',

    'corsheaders.middleware.CorsMiddleware',
]
CORS_ORIGIN_WHITELIST = [
     'http://localhost:3000'
]
CSRF_TRUSTED_ORIGINS = [
     'http://localhost:3000'
]


ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'


# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}


# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/

STATIC_URL = 'static/'

# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

今回、フロントサイドはReactサーバーが対応しているため、

INSTALLED_APPS = [

    "bbs.apps.BbsConfig",
    'corsheaders',
    'rest_framework',    

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',

    'corsheaders.middleware.CorsMiddleware',
]
CORS_ORIGIN_WHITELIST = [
     'http://localhost:3000'
]
CSRF_TRUSTED_ORIGINS = [
     'http://localhost:3000'
]

このように仕立てた。CORSの許可リストとCSRF検証をする際のオリジンとしてReactサーバーを指定。

INSTALLED_APPS とMIDDLEWARE にもcorsheaders を含めた。

それ以外の設定は通常通り。

フロントサイドのコード解説

Viteについて

frontend というプロジェクトを作るため、

npm create vite@latest frontend --template react

このコマンドを実行、React、JavaScriptを選び、

cd frontend
npm install
npm run dev

とした。

参照: create-react-app コマンドはもう使えないので、ViteでReactプロジェクトをつくる

vite.config.js

通常、Viteサーバーは5173 ポートで起動するようになっている。

参照: https://ja.vite.dev/config/server-options.html

これではCRAからの移行で、3000ポートを許可する設定にしているサーバーサイドまで変更しなければならない。

そこで、Viteサーバーを3000ポートで起動させるようにした。 vite.config.jsを編集する

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
    plugins: [react()],
    
    server: {
        port: 3000, // 使用するポート番号を指定
        proxy: {
            '/api': {
                target: 'http://localhost:8000/',
                changeOrigin: true,
            },
        },
    },
})

更に、CRAではpackage.jsonにproxyを書いていたが、Viteでは通用しない。vite.config.jsにはサーバーサイドのプロキシ設定も含めておく。

src/components/

今回は、djangoのテンプレート継承の概念を取り入れ、components ディレクトリ内の構造を以下のように仕立てた。

bbs
|--Base.jsx
|--Detail.jsx
|--Edit.jsx
|--Index.jsx

Base.jsxを親コンポーネントとして、他3つの子コンポーネントが呼び出される。

src/components/Base.jsx

import React, { useEffect,useState } from 'react';
import Index from "./Index";
import Detail from "./Detail";
import Edit from "./Edit";

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Link } from "react-router-dom";

const Base = () => {

    return (
        <>
            <BrowserRouter>
                <header className="bg-primary">
                    <h1>
                        <Link className="text-white text-decoration-none" to={"/"}>簡易掲示板</Link>
                    </h1>
                </header>

                <main className="container">
                        <Routes>
                            <Route path={`/`}           element={<Index />} />
                            <Route path={`/topic/:id`}  element={<Detail />} />
                            <Route path={`/topic/edit/:id`}  element={<Edit />} />
                        </Routes>
                </main>
            </BrowserRouter>

        </>
    )
};

export default Base;

基本となるコンポーネント。

react-router-domを使用して、URLに応じて呼び出すコンポーネントを指定している。

BrowserRouter コンポーネントでラップすることで、RoutesやLinkが有効になる。

src/components/Index.jsx

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

import { Link } from "react-router-dom";


const Index = () => {

    const [topics, setTopics] = useState({});
    const [categories, setCategories] = useState({});

    const [toggleForm, setToggleForm] = useState(false);

    // フォーム入力をする時はStateではなくuseRefを使う。
    // 投稿完了時、初期化できないのでStateにする。
    /*
    const newTopic = useRef({});
    const newCategory = useRef({});
    */

    const [newTopic, setNewTopic]       = useState({});
    const [newCategory, setNewCategory] = useState({});


    useEffect(() => {
        // TODO:ここでTopicをロードする。
        loadTopics();
        loadCategories();
    }, []);


    // 今回JWT認証はオミットしたため、CSRFトークンを取得する。
    const getCsrfToken = async () => {
        try {
            const response = await axios.get("/api/csrf-token/");
            return response.data.csrfToken;
        } catch (error) {
            console.error('Error fetching CSRF token:', error);
            return null;
        }
    }


    const loadTopics = async () => {
        try {
            const response = await axios.get("/api/topics/");

            // { 1: { オブジェクト,  }, 2: {...}, 3: {...} } この形式に変換する
            const processed = {}; 
            for (let topic of response.data){
                processed[topic.id] = topic;
            }
            setTopics(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const loadCategories = async () => {
        try {
            const response = await axios.get("/api/categories/");

            // { 1: { オブジェクト,  }, 2: {...}, 3: {...} } この形式に変換する
            const processed = {}; 
            for (let category of response.data){
                processed[category.id] = category;
            }
            setCategories(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }


    const submitTopic = async () => {
        try {
            const csrfToken = await getCsrfToken();
            console.log(csrfToken);
            console.log(newTopic);
            const response = await axios.post("/api/topics/", newTopic,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            console.log(response);

            setNewTopic({});
            loadTopics();

        } catch (error) {

            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    
    const deleteTopic = async (id) => {
        if( !confirm("本当に削除しますか?\n紐付いているリプライも削除されます。") ){
            return false;
        }

        try {
            const csrfToken = await getCsrfToken();
            console.log(csrfToken);
            const response = await axios.delete(`/api/topics/${id}/`,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            console.log(response);
            loadTopics();
        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }

    }

    const submitCategory = async () => {
        try {
            const csrfToken = await getCsrfToken();
            console.log(csrfToken);
            const response = await axios.post("/api/categories/", newCategory,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            console.log(response);
            setNewCategory({});

            loadCategories();

        } catch (error) {

            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    
    const deleteCategory = async (id) => {
        if( !confirm("本当に削除しますか?\n紐付いている投稿やリプライも削除されます。") ){
            return false;
        }

        try {
            const csrfToken = await getCsrfToken();
            console.log(csrfToken);
            const response = await axios.delete(`/api/categories/${id}/`,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            console.log(response);
            loadTopics();
            loadCategories();
        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }

    }


    const handleNewTopic = (e) => {
        /*
        console.log(e.currentTarget);
        console.log(e.currentTarget.name);
        console.log(e.currentTarget.value);

        console.log(e.target);
        console.log(e.target.name);
        console.log(e.target.value);
        */

        try {
            setNewTopic( (prevNewTopic) => {
                const updatedNewTopic = { ...prevNewTopic };
                updatedNewTopic[e.target.name] = e.target.value;
                return updatedNewTopic;
            });
        }
        catch (error) {
            console.log(error);
        }
    }


    const handleNewCategory = (e) => {
        try {
            setNewCategory( (prevNewCategory) => {
                const updatedNewCategory = { ...prevNewCategory };
                updatedNewCategory[e.target.name] = e.target.value;
                return updatedNewCategory;
            });
        }
        catch (error) {
            console.log(error);
        }
    }


    const handleToggleForm = () => {
        setToggleForm( (prevToggleForm) => !prevToggleForm );
    }


    
    return (
        <>


            { toggleForm ? (
                <>
                <h2>カテゴリ一覧</h2>

                <form>
                    <input className="form-control w-auto d-inline-block" type="text" placeholder="カテゴリ名" name="name" onChange={handleNewCategory} value={newCategory.name || "" } />
                    <input className="btn btn-outline-primary" type="button" onClick={submitCategory} value="作成" />
                </form>

                <ul>
                { Object.entries(categories).map( ([id, category]) => (
                    <li key={id}>{ category.name } <span className="mx-1 btn btn-outline-danger" onClick={ () => { deleteCategory(category.id) } }>削除</span>
                    </li>
                ))}
                </ul>

                <span className="btn btn-outline-danger" onClick={handleToggleForm}>戻る</span>
                </>

            ) : (

                <>
                <h2>新規作成</h2>
                <form>
                    <select className="form-select w-auto d-inline-block" name="category" onChange={ handleNewTopic } value={ newTopic.category || "" }>
                        <option value="">カテゴリを選んでください</option>
                        { Object.entries(categories).map( ([id, category]) => (
                        <option key={id} value={category.id}>{ category.name }</option>
                        )) }
                    </select>

                    <span className="fs-4 d-inline-block mx-2" onClick={handleToggleForm} >
                        <i className="fa-regular fa-square-plus"></i>
                    </span>

                    <textarea className="form-control" rows="4" name="comment" placeholder="コメントを入力してください" onChange={ handleNewTopic } value={ newTopic.comment || "" }></textarea>
                    <input className="form-control" type="button" onClick={ submitTopic } value="投稿"/>
                </form>
                </>
            )

            }

            <hr />

            { Object.entries(topics).map( ([id, topic]) => (
                <div className="border my-2" key={id}>
                    <div className="bg-secondary-subtle">

                <div>投稿日時: { topic.created_at }</div>
                {/* <div>投稿日時: { formatDate(topic.created_at) } </div>  */}
                        <div>カテゴリ: { categories[topic.category] && categories[topic.category].name }</div>
                    </div>
                    <div className="fs-5 p-2">{ topic.comment }</div>

                    <div className="text-end py-2">

                        <Link className="mx-1 btn btn-outline-primary" to={`/topic/${topic.id}/`} >詳細</Link>
                        <Link className="mx-1 btn btn-outline-success" to={`/topic/edit/${topic.id}/`}>編集</Link>
                        <span className="mx-1 btn btn-outline-danger" onClick={ () => { deleteTopic(topic.id) } }>削除</span>
                    </div>

                </div>
            )) }
        </>
    );  
};

export default Index;

useRefについて

useRefはuseStateと同様、値を格納することができる。

ただし、useRefは値が変わっても、再レンダリングしない。

そこで、今回はtextareaなどのフォームにuseRefを使用してみた。

これで、textareaの入力のたびに再レンダリングすることはなくなった。だが、投稿後にtextareaを初期化することができなくなった。

useRefに初期値を入れても、textareaのvalueは再レンダリングすることはない。

よって、フォームの入力値は、これまで通りuseStateで値を格納することにした。

e.currentTarget と e.target

    const handleNewTopic = (e) => {
        /*
        console.log(e.currentTarget);
        console.log(e.currentTarget.name);
        console.log(e.currentTarget.value);

        console.log(e.target);
        console.log(e.target.name);
        console.log(e.target.value);
        */

        try {
            setNewTopic( (prevNewTopic) => {
                const updatedNewTopic = { ...prevNewTopic };
                updatedNewTopic[e.target.name] = e.target.value;
                return updatedNewTopic;
            });
        }
        catch (error) {
            console.log(error);
        }
    }

このコードの currentTarget、target どちらのconsole.log も同じ値を表示した。

だが、setStateをする時、currentTargetの方は、nullと判断されて値を入れることはできなかった。

src/components/Detail.jsx

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

import axios from "axios";
import { useParams } from "react-router-dom";
import { Link } from "react-router-dom";

const Detail = () => {
    const { id } = useParams();
    const [topic , setTopic] = useState({});
    const [categories, setCategories] = useState({});
    const [replies , setReplies] = useState({});

    //const newReply = useRef({ topic : id });

    const [newReply, setNewReply] = useState({ topic : id });

    useEffect(() => {
        loadTopic();
        loadCategories();
        loadReplies();
    }, []);

    const getCsrfToken = async () => {
        try {
            const response = await axios.get("/api/csrf-token/");
            return response.data.csrfToken;
        } catch (error) {
            console.error('Error fetching CSRF token:', error);
            return null;
        }
    }

    // GET: トピックリストを取得
    const loadTopic = async () => {

        try {
            const response = await axios.get(`/api/topics/${id}/`);
            console.log(response.data);

            setTopic(response.data);
        } catch (error) {

            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const loadCategories = async () => {
        try {
            const response = await axios.get("/api/categories/");

            // { 1: { オブジェクト,  }, 2: {...}, 3: {...} } この形式に変換する
            const processed = {};
            for (let category of response.data){
                processed[category.id] = category;
            }
            setCategories(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const loadReplies = async () => {
        try {
            const response = await axios.get(`/api/replies/?id=${id}`);

            const processed = {};
            for (let reply of response.data){
                processed[reply.id] = reply;
            }
            setReplies(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }


    const handleNewReply = (e) => {

        setNewReply( (prevNewReply) => {
            const updatedNewReply = { ...prevNewReply };
            updatedNewReply[e.target.name] = e.target.value;

            return updatedNewReply;
        });

    }

    const submitReply = async () => {
        try {
            const csrfToken = await getCsrfToken();
            console.log(csrfToken);
            const response = await axios.post("/api/replies/", newReply,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            console.log(response);
            setNewReply({ topic : id });
            loadReplies();

        } catch (error) {

            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    return (
        <>
        { topic.id ?
            <>
                <div className="border my-2">
                    <div className="bg-secondary-subtle">
                        <div>投稿日時: { topic.created_at }</div>
                        <div>カテゴリ: { categories[topic.category] && categories[topic.category].name }</div>
                    </div>
                    <div className="fs-5 p-2">{ topic.comment }</div>

                    <div className="text-end py-2">
                        <Link className="mx-1 btn btn-outline-success" to={`/topic/edit/${topic.id}/`}>編集</Link>
                        <span className="mx-1 btn btn-outline-danger" onClick={ () => { deleteTopic(topic.id) } }>削除</span>
                    </div>
                </div>

                <hr />

                <h2>リプライ一覧</h2>

                <form>
                    <textarea className="form-control" rows="4" name="comment" placeholder="コメントを入力してください" onChange={ handleNewReply } value={ newReply.comment || "" }></textarea>
                    <input className="form-control" type="button" onClick={ submitReply } value="投稿"/>
                </form>

                { replies ? 
                    Object.entries(replies).map( ([id, reply]) => (
                        <div className="border my-2" key={id}>
                            <div className="bg-secondary-subtle">
                                <div>投稿日時: { reply.created_at }</div>
                            </div>
                            <div className="fs-5 p-2">
                                {reply.comment}
                            </div>
                        </div>
                    ))
                : <div>リプライはありません</div> }

            </>
            : <div>トピックがありません</div> }
        </>
    );
};

export default Detail;

詳細ページのコンポーネントである。

csrfTokenの部分は重複しているので、次の開発ではutilsディレクトリに関数をまとめておく。

URL引数

react-router-dom の useParams を使い、URL内の引数を取り出している。

const { id } = useParams();

Base.jsxで定義した

<Route path={`/topic/:id`}  element={<Detail />} />

ここから、idを取り出している。どうやら、型の定義まではできないらしい。

参照: https://reactrouter.com/start/library/url-values

src/components/Edit.jsx

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

import axios from "axios";
import { useParams } from "react-router-dom";

const Edit = () => {

    const { id } = useParams();
    const [topic , setTopic] = useState({});
    const [categories, setCategories] = useState({});

    useEffect(() => {
        loadTopic();
        loadCategories();
    }, []);

    const getCsrfToken = async () => {
        try {
            const response = await axios.get("/api/csrf-token/");
            return response.data.csrfToken;
        } catch (error) {
            console.error('Error fetching CSRF token:', error);
            return null;
        }
    }

    const loadTopic = async () => {
        try {
            const response = await axios.get(`/api/topics/${id}/`);
            console.log(response.data);

            // HACK: ここで入力必須ではないcreated_atを除外する(日付フォーマットが違うため400エラーになる。)
            // 密結合になるので、もっと良い方法を考えるべき
            const data = { ...response.data };
            delete data.created_at;

            setTopic(data);

        } catch (error) {

            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const loadCategories = async () => {
        try {
            const response = await axios.get("/api/categories/");

            // { 1: { オブジェクト,  }, 2: {...}, 3: {...} } この形式に変換する
            const processed = {};
            for (let category of response.data){
                processed[category.id] = category;
            }
            setCategories(processed);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }

    const handleTopic = (e) => {
        try {
            setTopic( (prevTopic) => {
                const updatedTopic  = { ...prevTopic };

                updatedTopic[e.target.name]   = e.target.value;
                return updatedTopic;
            });
        }
        catch (error) {
            console.log(error);
        }
    }

    const submitEditTopic = async () => {

        try {
            const csrfToken = await getCsrfToken();
            console.log(csrfToken);

            const response = await axios.put(`/api/topics/${id}/`, topic,
                { headers: { 'X-CSRFToken': csrfToken } },
            );
            console.log(response);

        } catch (error) {
            console.error(error);
            if (error.response) {
                console.error('Response Error:', error.response.data);
                console.error('Response Status:', error.response.status);
                console.error('Response Headers:', error.response.headers);
            } else {
                console.error('Request Error:', error.message);
            }
        }
    }


    return (
        <>

        { topic.id ?
            <>
            <h2>編集フォーム</h2>

            <form>
                <select className="form-select w-auto d-inline-block" name="category" onChange={ handleTopic } value={topic.category || "" }>
                    <option value="">カテゴリを選んでください</option>
                    { Object.entries(categories).map( ([id, category]) => (
                    <option key={id} value={category.id}>{ category.name }</option>
                    )) }
                </select>

                <textarea className="form-control" rows="4" name="comment" placeholder="コメントを入力してください" onChange={ handleTopic } value={topic.comment}></textarea>
                <input className="form-control" type="button" onClick={ submitEditTopic } value="編集"/>
            </form>

            </>
            : <div>トピックがありません</div> }
        </>
    );
};

export default Edit;

動かすとこうなる

トップページ

詳細ページ

編集ページ

ソースコード

https://github.com/seiya0723/drf-react-spa-bbs

結論

この開発作業により、

  • CSRF検証のタイミングと発火条件
  • Viteの仕組み
  • useRef,useEffect,useStateの仕組み
  • 日付のSerializerの書き方
  • ModelViewSet の バリデーションエラー時の挙動

がわかった。

また、スプレッド構文、短絡評価、三項演算子など、頻繁に使うモダンJavaScript構文も慣れてきた。

ただ、全体的に似たような処理が続いている。更に簡略化させていきたい。Next.jsの採用も一考だ。

今後の課題

  • react-router-domではなく、Next.jsを使う
  • JWT認証を実装
  • 多対多の実装
  • 検索機能の実装
  • ページネーション機能の実装
  • WebSocketの実装
  • 画像のアップロード
  • DjangoMessageFrameworkのような通知バナー表示

react-router-domではなく、Next.jsを使う

Next.jsであれば、pageディレクトリにjsxファイルを配置するだけで、簡単にページURLを構築できる。

とはいえ、React Router v7とRemix の統合により、素のReactでもSSRができるようになった。

そうなってくると、相対的にNext.jsの優位性が霞んでくる。

参照: https://qiita.com/Sicut_study/items/7dc1b0cdcc1bee210f05

もっとも、Next.jsを使用したサイトは数多くあるため、すぐに不要になるとは思えないが。

https://ja.wikipedia.org/wiki/Next.js

ちなみに、Next.jsのインストールとプロジェクト作成方法は、

npx create-next-app@latest my-next-app

プロジェクト構成は以下の通り。

my-next-app/
├── public/
├── src/ (任意)
├── pages/
│   ├── api/
│   ├── index.js
│   └── _app.js
├── styles/
│   ├── globals.css
│   └── Home.module.css
├── node_modules/
├── .next/
├── package.json
├── next.config.js
└── README.md

ViteのReactと同じく、npm run dev で動作させる。

JWT認証を実装

下記記事のJWT認証は、トークンの再生成まではしていないため、修正する。

Django(DRF)+ReactのSPAでJWTを使った認証を実装する

多対多の実装

DRF+ReactのSPAで多対多を実装する場合、GET、POSTともに、以下のようなJSONを送る必要がある。

{
    "title": "My First Article",
    "content": "This is the content.",
    "tags": [1, 2, 3]
}

このように紐づくモデルのidを配列でやり取りする。

タグの追加、削除を頻繁に行う場合、PATCHの挙動をどうするか考える必要もある。

ただし、RestfulなAPI設計とはかけ離れるので、基本はPUTで対応したほうが良いだろう。

検索機能の実装

DRF(ModelSetView)で検索機能を実装する時、django-filterを使う方法とSearchFilterを使う方法の2パターンが考えられる。

django-filter

django-filter は、インストールが必要。

pip install django-filter

フィルタークラスを作り、ModelViewSetに与える。

import django_filters

from .models import Topic

# フィルタークラスを作る。コメントと日時の範囲検索
class TopicFilter(django_filters.FilterSet):
    comment     = django_filters.CharFilter(lookup_expr='icontains')
    created_at  = django_filters.DateFromToRangeFilter()

    class Meta:
        model   = Topic
        fields  = ["comment", "created_at"]


from rest_framework.viewsets import ModelViewSet
from django_filters.rest_framework import DjangoFilterBackend

from .models import Topic
from .serializers import TopicSerializer

# ModelViewSetでDjangoFilterBackend を使って有効化。先のTopicFilterを指定。
class TopicViewSet(ModelViewSet):
    queryset            = Topic.objects.all()
    serializer_class    = TopicSerializer

    filter_backends     = [DjangoFilterBackend]
    filterset_class     = TopicFilter

この django-filter はスペース区切りの検索に対応しているようだ。

SearchFilter

SearchFilter はDRFに含まれる。インストールは不要。ただし機能は限定されている。

スペース区切りの検索に対応していない。

from rest_framework.filters import SearchFilter

class TopicViewSet(ModelViewSet):
    queryset            = Topic.objects.all()
    serializer_class    = TopicSerializer

    filter_backends     = [SearchFilter]
    search_fields       = ["title", "content"]

この他にも、カスタムロジックを実装する方法や双方を併用する形もある。

どんな方法が良いのか、状況に応じて一番良いと思われるものを模索していきたいところだ。

ページネーション機能の実装

DRFでは、ページネーションがデフォルトで動作するようになっている。

その仕組みはdjangoのものとは違い、非常にシンプルで、検索との両立もサポートしている。

from rest_framework.pagination import PageNumberPagination
from rest_framework.viewsets import ModelViewSet
from .models import Topic
from .serializers import TopicSerializer


# 1ページに10件表示する。
class CustomPagination(PageNumberPagination):
    page_size = 10

class TopicViewSet(ModelViewSet):
    queryset = Topic.objects.all()
    serializer_class = TopicSerializer
    pagination_class = CustomPagination

この場合、例えば /topic/?search=Django 教科書&page=2 などとすれば、検索した状態で2ページ目が表示される。

WebSocketの実装

WebSocketをReactで使う場合、useEffect(ページロード時)にWebSocketのオブジェクトを作り、イベントのコールバックも同時に定義しておけば良いだけ。

メッセージを受け取ったら、Stateを使って値をセットするようになる。

DjangoでWebSocketを使って、チャットサイトを作る

以下は、ReactでWebSocketを実装したときのサンプル。

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

const WebSocketComponent = () => {
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);
  const [ws, setWs] = useState(null);

  // WebSocketの接続
  useEffect(() => {
    // WebSocketサーバーのURL
    const socket = new WebSocket('ws://localhost:8080');

    socket.onopen = () => {
      console.log('WebSocket connection established');
    };

    socket.onmessage = (event) => {
      // サーバーから受け取ったメッセージを追加
      setMessages((prevMessages) => [...prevMessages, event.data]);
    };

    socket.onerror = (error) => {
      console.error('WebSocket error:', error);
    };

    socket.onclose = () => {
      console.log('WebSocket connection closed');
    };

    // WebSocketをstateにセット
    setWs(socket);

    // コンポーネントがアンマウントされた時にWebSocketを閉じる
    return () => {
      if (socket) {
        socket.close();
      }
    };
  }, []);

  // メッセージ送信
  const sendMessage = () => {
    if (ws && message) {
      ws.send(message);
      setMessage('');  // メッセージ送信後、入力をクリア
    }
  };

  return (
    <div>
      <h1>WebSocket Communication</h1>
      <div>
        <input 
          type="text" 
          value={message} 
          onChange={(e) => setMessage(e.target.value)} 
          placeholder="Type a message" 
        />
        <button onClick={sendMessage}>Send</button>
      </div>
      <div>
        <h2>Received Messages:</h2>
        <ul>
          {messages.map((msg, index) => (
            <li key={index}>{msg}</li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default WebSocketComponent;

画像のアップロード

リクエストのボディには、FormDataオブジェクトを与えることで動作する。(これまでのプレーンオブジェクトではない)

FormDataオブジェクトは内部に画像が含まれる場合、自動的に Content-Type: multipart/form-data のヘッダーを指定してくれる。

一方、プレーンオブジェクトの場合、Content-Type: application/json のデフォルトで送信される。

FormDataオブジェクトのほうが柔軟性が高いため、この先は、リクエストのボディにはFormDataオブジェクトを与える。

FormDataをformタグではなく、オブジェクトにキーと値をセットした上でAjax送信

DjangoMessageFrameworkのような通知バナー表示

React側で、バナー表示の仕組みを用意する必要がある。

DRF側では、Serializerにバリデーションエラー時のメッセージを用意しておく。

スポンサーリンク