⚛️

React+DjangoRESTFramworkでSPAのToDoアプリをつくる

2023/10/09に公開

背景

以前からDjangoにおけるWebアプリケーション作成において、ReactやFlutterなどのフロントエンドのフレームワークとの結合を考えております。
今回は、手始めにフロントエンドにReactを、バックエンドはDjango REST Frameworkを利用したRESTful APIで行なうことを目的にToDoアプリの作成をしながら学習した記録です。

先人たちの知恵をお借りするなどして解決できたことを、この場をお借りして感謝するとともに、大変恐縮ですが自分のメモとして、こちらへまとめておきます。

はじめに

ReactやPython、Djangoなどの技術仕様も日に日に変化し、最新バージョンも上がっているため、できる限り新しい情報で学習したくたくさんのサイトや技術ブログを参照しました。
やはり、自分自身のやりたいことと合致する技術や環境での記事はなかなか見つからないものの、そこは応用も効かせながら進める必要があることを改めて痛感しました。

MVCとSPAとは

1) MVC

Model, View, Controllerと呼ばれる3つの機能から成るアーキテクチャであり、それぞれの頭文字をとってMVCと呼びます。
DjangoではMTVと呼び、TemplateはMVCのViewにあたり、ViewはMVCのControllerにあたるものの、呼び方が異なるだけで仕組みはMVCと同じです。

それぞれの役割をMVCを例とすると、以下となります。

役割 説明
Model ビジネスロジックと呼ばれデータベースへのデータ保存や取得、更新、削除などを担当。
View HTMLを形作るためのもので、特定のパスにアクセスに対してViewからHTMLを生成してクライアントへの送信を担当。
Controller クライアントからのリクエストに応じてModelへの命令やバリデーション、Viewにセットする値の管理などを担当。

SPAとMVCはどちらも、ルーティングに結びついた各処理機構でデータを通信し、レスポンスに応じて返された値をHTMLに埋め込み画面に反映させる点で共通しています。
ただし、SPAではViewの部分はMVCアーキテクチャーから分離されたようになっており、ここがSPAとMVCでの大きな違いです。
言い換えると、以下のようになります。

設計構造 説明
MVC 一つのアーキテクチャーがフルスタックに、ルーティングやデータベースとの通信、HTMLの生成をこなしている。
SPA ルーティングとHTMLの生成などが分離され、Viewに近い部分が一つのアーキテクチャーとして機能する。

SPAを進める場合は、MVCのViewを除いたModelとControllerのみでバックエンドを開発することができます。

2) SPA

Single Page Applicationの略であり、単一のページで、Webアプリケーションを構成する設計構造のこと。
SPAで実装されたページでは遷移をせずにコンテンツが切り替わるため、ユーザー体験(UX)の向上に繋がります。
多くのWebサイトでは、ReactやVueといったJavaScriptフレームワークが使われています。
これらのフレームワークはSPAと呼ばれ、Webアプリケーションを作る際によく採用されており、UI/UXから開発効率まで多くのメリットをもたらします。

3)-a.SPAのメリット・デメリット

メリット:
  • ページ遷移が速いこと。
  • 近年はフロントエンド技術の革新に伴ないJavaScriptフレームワークが開発に使われるようになったため、フロントエンドとバックエンドが以前よりも分離されるようになったこと。
  • 速度改善によるユーザビリティの向上。
  • フロントエンドとバックエンドそれぞれの開発に集中することができること。
  • フロントエンドとバックエンドを別々にデプロイ、再起動できること。
デメリット:
  • 学習コストがフルスタックフレームワークによる開発よりも高くなる傾向にあること。(フレームワーク特有の概念を学習しなければならない点)
  • 実装コストも高くなる場合があること。(SEO対策など)
  • コード量により初期表示にかかる時間が増えてしまうこと。

ただし、以前に比べSPA開発のエコシステムやフロントエンド技術が大きく充実してきたため、対策は以前よりしやすくなりました。

Reactとは

Meta(旧Facebook)が開発した、データバインディング/仮想DOM/コンポーネント機能を備えたJavaScriptフレームワーク。
React Nativeというモバイルアプリ用のフレームワークもあり、Reactと似たような扱い方ができることでReactを選択した開発のメリットはかなり大きいと言えそうです。

https://react.dev/

https://reactnative.dev/

1.フロントエンドの実装

1-1) Reactの環境構築

Node.jsとnpmのインストール

Windowsの場合、Node.jsの公式ページにアクセスし最新版のインストーラーをダウンロードします。
ダウンロードしたインストーラーをダブルクリックして起動後、インストーラーの説明に沿って実行していくことでインストールできます。
image.png

https://nodejs.org/ja

image.png

コマンドプロンプトまたはVSCodeのターミナルなどから以下のコマンドを実行し、インストールしたバージョンを表示して再確認します。

node --version

# v20.8.0

npmのバージョンも確認します。

npm --version

# 10.1.0

念のため、以下のコマンドで最新版にアップデートしてみましょう。

npm install -g npm

# changed 33 packages in 13s
# 
# 29 packages are looking for funding
#   run `npm fund` for details

npm --version

# 10.2.0

最新版にアップデートされました。

ここまででReact環境をつくるための下準備が整いました。ここからReact環境をつくります。

以下のコマンドを実行して、Reactアプリケーションを作成します。

npx create-react-app todo-frontend
cd todo-frontend
npm start

:::note info
「todo-frontend」はアプリケーション名ですので、任意で指定してください。
:::
:::note warn
1行目の npx は誤字ではありません。これはnpm 5.2から利用できるパッケージランナーツールとのこと。
:::

npm startで環境の起動までできました。
以下のURLへアクセスして、下の画面が表示されたら成功です。

http://localhost:3000

image.png

ディレクトリ構成
root
└ todo-frontend    # 以下が追加される
  ├ node_modules
  ├ public
  ├ src
    ├ App.css
    ├ App.js
    ├ App.test.js
    ├ index.css
    ├ index.js
    ├ logo.svg
    ├ reportWebVitals.js
    └ setupTests.js
  ├ .gitignore
  ├ package-lock.json
  ├ package.json
  └ README.md

1-2) Reactのルーティング

React自体はルーティング機能を持っていません。
そのため、デファクトスタンダードとして利用されるreact-router-domを利用するために、下記コマンドを実行してインストールします。

npm install react-router-dom@latest

インストール完了後、ルーティングを設定していくためにsrc直下へconfigsディレクトリを作成し、かつ、その中にRouter.jsxを作成します。
ファイル作成後、下記をファイル内に追記してください。

/todo-frontend/src/configs/Router.jsx
import { createBrowserRouter } from 'react-router-dom';

const router = createBrowserRouter([
]);

export default router;

ページへのパスの割り当てはのちほど実装するため、ここではパス割り当ての無いまま進めます。
ルーティング用のファイルを作成したら、既に作成されているApp.jsにインポートします。
コードを下記の内容に書き換えてください。

/todo-frontend/src/App.js
import { RouterProvider } from "react-router-dom";
import router from "./configs/Router";

function App() {
  return (
    <RouterProvider router={router} />
  );
}

export default App;
ディレクトリ構成
root
└ todo-frontend
  ├ node_modules
  ├ public
  ├ src
    ├ App.css
    ├ App.js
    ├ App.test.js
    ├ configs       # 追加
      └ Router.jsx  # 追加
    ├ index.css
    ├ index.js
    ├ logo.svg
    ├ reportWebVitals.js
    └ setupTests.js
  ├ .gitignore
  ├ package-lock.json
  ├ package.json
  └ README.md

1-3) ページの追加

Reactではファイル構成の型が特に決まっていないため、プロジェクトの規模や方針に合わせて構成をその都度考える必要があるようです。

今回は機能ごとにディレクトリを分けることで進めます。
具体的なイメージは以下の通りです。

ディレクトリ構成
root
└ todo-frontend
  ├ node_modules
  ├ public
  ├ src
    ├ App.css
    ├ App.js
    ├ App.test.js
    ├ common         # これ以降で作成予定
      └ api          # これ以降で作成予定
        └ todo.js    # これ以降で作成予定
    ├ components     # これ以降で作成予定
      └ Top          # これ以降で作成予定
        └ indez.jsx  # これ以降で作成予定
    ├ configs
      └ Router.jsx
    ├ index.css
    ├ index.js
    ├ logo.svg
    ├ reportWebVitals.js
    └ setupTests.js
  ├ .gitignore
  ├ package-lock.json
  ├ package.json
  └ README.md

ここから実際にページをつくります。
src直下にcomponents/Topディレクトリを、その直下にindex.jsxを作成した上で、下記のコードを追記します。

/todo-frontend/src/components/Top/index.jsx
const Top = () => {
 return (
   <>
     Hello World!
   </>
 )
};
export default Top;

これでページとなるコンポーネントを作成できましたが、作成したコンポーネントをpathに紐付いていないため、ルーティングを設定して紐付けます。

/todo-frontend/src/configs/Router.jsx
import { createBrowserRouter } from 'react-router-dom';
+ import Top from '../components/Top';

const router = createBrowserRouter([
+   { path: "/", element: <Top /> },
]);

export default router;

これでページを作成することができました。
再び以下のURLにアクセスすると、「Hello World!」と表示されます。

http://localhost:3000

image.png

1-4) 画面の作り込み

次にMaterial UIというデザインコンポーネントを使って画面をつくり込んでいきます。

  • Material UI = React用のマテリアルデザインUIコンポーネント。テキストフィールドやボタンなどの機能単位でコンポーネントが提供されている。

https://material-ui.com/

Material UIをインストールするため、以下のコマンドを実行します。

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

ここからMaterial UIを使って実装を進めていきます。
最終的な完成イメージ

image.png

画面は、最上部にテキストフィールドと作成ボタンを設置し、その下にToDoリストを並べる構成です。
ToDoのそれぞれにチェックボックスと削除ボタンがあるため、チェック機能と削除機能を追加します。

ここまで作成したsrc/components/Top/index.jsxにToDoを入力するフィールドと作成ボタンのためのコードを追記していきます。
インポートしている箇所はMaterial UIのプラグインを読み込んでいます。

/todo-frontend/src/components/Top/index.jsx
+ import Button from '@mui/material/Button';
+ import Box from '@mui/material/Box';
+ import Container from '@mui/material/Container';
+ import TextField from '@mui/material/TextField';

const Top = () => {
 return (
-    <>
+    <Container maxWidth="xs">
-      Hello World!
+      <Box display="flex" justifyContent="space-between" mt={4} mb={4}>
+        <TextField label="やること" variant="outlined" size="small" />
+        <Button variant="contained" color="primary">作成</Button>
+      </Box>
-    </>
+    </Container>
 )
};
export default Top;

画面を確認すると、テキスト入力のフィールドとボタンが追加されています。
image.png

さらにToDoリストを表示する処理を追加します。

/todo-frontend/src/components/Top/index.jsx
+ import { useState } from 'react';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
+ import FormGroup from '@mui/material/FormGroup';
+ import FormControlLabel from '@mui/material/FormControlLabel';
+ import Checkbox from '@mui/material/Checkbox';
import Container from '@mui/material/Container';
import TextField from '@mui/material/TextField';

const Top = () => {

+   const [todoList, setTodoList] = useState(
+     [
+       {id: 1, name: '洗濯'},
+       {id: 2, name: '掃除'},
+       {id: 3, name:'食器洗い'}
+   ]);

  return (
    <Container maxWidth="xs">
      <Box display="flex" justifyContent="space-between" mt={4} mb={4}>
        <TextField label="やること" variant="outlined" size="small" />
        <Button variant="contained" color="primary">作成</Button>
      </Box>
+       <FormGroup>
+         {todoList.map((todo, index) => {
+           return (
+             <Box key={index} display="flex" justifyContent="space-between" mb={1}>
+               <FormControlLabel
+                 control={<Checkbox value={todo.id} />}
+                 label={todo.name}
+               />
+               <Button variant="outlined">削除</Button>
+             </Box>
+           )
+         })}
+       </FormGroup>
    </Container>
  )
};
export default Top;

image.png

新たにインポートしたuseStateは、データバインディングを行なうためのものです。
todoListに値が格納され、値を更新する場合はsetTodoListを使用します。

map関数内でkey={todo.id}としている箇所は、どの要素が追加/更新/削除されたのかをReactが検知するために指定しています。
要素とkeyは1:1の関係になる必要があるため、keyに渡す値は必ず一意でなければなりません。

1-5) 追加、削除処理の実装

ToDoの追加、削除処理の実装をしていきます。

処理 説明
handleCreate ToDoを新たに追加します。
handleSetTodo 入力している値をtodoに格納します。ここで追加された値は、追加ボタンを押すとhandleCreateでtodoListに追加されます。ただし、新しくtodoListに追加する際のidは仮でMath.floor(Math.random()*10000)とします。最終的な実装では、データベースに登録した時に付与された一意なidを入れますが、バックエンドはまだ実装をしていないため、ここでは仮の設定として進めます。
handleDelete 削除ボタンを押したときにToDoリストから該当のToDoを取り除きます。
/todo-frontend/src/components/Top/index.jsx
import { useState } from 'react';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Checkbox from '@mui/material/Checkbox';
import Container from '@mui/material/Container';
import TextField from '@mui/material/TextField';

const Top = () => {

  const [todoList, setTodoList] = useState(
    [
      {id: 1, name: '洗濯'},
      {id: 2, name: '掃除'},
      {id: 3, name:'食器洗い'}
  ]);

+   const [todoName, setTodoName] = useState('');
+
+   const handleSetTodo = (e) => {
+     setTodoName(e.target.value);
+   };
+
+   const handleCreate = () => {
+     if ( todoName === '' || todoList.some( value => todoName === value.name ) ) return;
+     setTodoList(todoList.concat({id: Math.floor(Math.random()*10000), name: todoName}));
+   };
+
+   const handleDelete = (e) => {
+     const todoId = e.currentTarget.dataset.id;
+     const list = todoList.filter( value => value['id'].toString() !== todoId);
+     setTodoList(list);
+   };

  return (
    <Container maxWidth="xs">
      <Box display="flex" justifyContent="space-between" mt={4} mb={4}>
        <TextField label="やること" variant="outlined" size="small" onChange={handleSetTodo} />
        <Button variant="contained" color="primary" onClick={handleCreate}>作成</Button>
      </Box>
      <FormGroup>
        {todoList.map((todo, index) => {
          return (
            <Box key={index} display="flex" justifyContent="space-between" mb={1}>
              <FormControlLabel
                control={<Checkbox value={todo.id} />}
                label={todo.name}
              />
              <Button variant="outlined" data-id={todo.id} onClick={handleDelete}>削除</Button>
            </Box>
          )
        })}
      </FormGroup>
    </Container>
  )
};
export default Top;

後半部分(returnより後ろ)は、作成した関数を使ってイベントを設定しています。
画面とイベント処理を実装できましたが、APIを使ってリクエストする処理がまだ残っています。

このあとバックエンドの実装を進めていきます。

2.バックエンドの実装

pythonは手元のPCローカルにインストール済みの前提で進めます。

2-1) Djangoの環境構築

バックエンドを実装します。

以下のコマンドを実行して、todo-backendディレクトリに移動します。
pythonのバージョンも再確認しておきます。

# rootディレクトリに戻る
cd ..

mkdir todo-backend
cd todo-backend
python --version

# Python 3.12.0

以下のコマンドを実行して、Python仮想環境を構築します。

python -m venv venv
cd venv\Scripts
activate.bat

# todo-backend/に戻る
(venv) cd ../..

:::note info
python -m venv venv の 2つめの「venv」は仮想環境名ですので、任意で指定してください。
:::

以下のコマンドでDjangoなどのフレームワークをインストールします。

(venv) pip install django
(venv) pip install djangorestframework
(venv) pip install django-cors-headers
  • djangorestframework = DjangoでRESTfulなAPIを作るための定番ライブラリ。正式名称はDjango Rest Frameworkで、簡単な設定だけでAPIでのCRUD処理を迅速に実装できる。
  • django-cors-headers = 後述するクロスオリジン問題を解決するためのライブラリ。

https://www.djangoproject.com/

https://www.django-rest-framework.org/

続いてDjango自体の環境設定をします。

(venv) django-admin startproject project .

python manage.py startapp todo
python manage.py migrate
python manage.py runserver

:::note info

  • django-admin startproject project . の「prroject」はプロジェクト名ですので、任意で指定してください。
  • python manage.py startapp todo の「todo」はアプリケーション名ですので、任意で指定してください。
    :::

以下のコマンドでpipをupgradeします。

(venv) python -m install --upgrade pip
ディレクトリ構成
root
├ todo-backend            # 追加
  ├ project               # 追加
    ├ __pychache__        # 追加
    ├ __init__.py         # 追加
    ├ asgi.py             # 追加
    ├ settings.py         # 追加
    ├ urls.py             # 追加
    ├ wsgi.py             # 追加
  ├ todo                  # 追加
    ├ migrations          # 追加
      └ __init__.py       # 追加
    ├ __init__.py         # 追加
    ├ admin.py            # 追加
    ├ apps.py             # 追加
    ├ models.py           # 追加
    ├ tests.py            # 追加
    └ views.py            # 追加
  ├ venv                  # 追加
    ├ Include             # 追加
    ├ Lib                 # 追加
    ├ Scripts             # 追加
    └ pyvenv.cfg          # 追加
  ├ db.sqlite3            # 追加
  └ manage.py             # 追加
└ todo-frontend
  ├ node_modules
  ├ public
  ├ src
    ├ App.css
    ├ App.js
    ├ App.test.js
    ├ common
      └ api
        └ todo.js
    ├ components
      └ Top
        └ indez.jsx
    ├ configs
      └ Router.jsx
    ├ index.css
    ├ index.js
    ├ logo.svg
    ├ reportWebVitals.js
    └ setupTests.js
  ├ .gitignore
  ├ package-lock.json
  ├ package.json
  └ README.md

python manage.py runserverを実行するとDjangoの環境が立ち上がり、下記URLへアクセスすると以下のような画面が表示されます。

http://127.0.0.1:8000

image.png

djangoプロジェクトのsettings.pyを設定します。

/todo-backend/project/settings.py
INSTALLED_APPS = [
   'django.contrib.admin',
   'django.contrib.auth',
   'django.contrib.contenttypes',
   'django.contrib.sessions',
   'django.contrib.messages',
   'django.contrib.staticfiles',
+    'rest_framework',
+    'todo',
]

2-2) Modelの実装

既に作成されているtodo/models.pyにモデルを実装します。ToDoアプリを作るため、ToDoのモデルをつくります。

/todo-backend/todo/models.py
from django.db import models

+ class Todo(models.Model):
+    name = models.CharField(max_length=64, blank=False, null=False)
+    checked = models.BooleanField(default=False)

+    def __str__(self):
+        return self.name

マイグレーションを生成し、データベースのマイグレートを実行し、ToDoリスト用のテーブルをつくります。

(venv) python manage.py makemigrations todo
(venv) python manage.py migrate

2-3) Serializerの実装

Serializer(シリアライザー)とはデータの入出力をするためのもので、serializeとdeserializeという処理を行います。

処理 説明
serialize 入力された値をモデルに合わせてバリデーションし、レコードに伝える。
deserialize レコードをアプリケーションで扱える形式に変更する。

Serializerの処理を実装するファイルはstartprojectやstartappでは作成されないため、自身で新たに作成します。

/todo-backend/todo/serializers.py
from rest_framework import serializers
from .models import Todo

class TodoSerializer(serializers.ModelSerializer):
   class Meta:
       model = Todo
       fields = ('id', 'name', 'checked')
ディレクトリ構成
root
├ todo-backend
  ├ project
    ├ __pychache__
    ├ __init__.py
    ├ asgi.py
    ├ settings.py
    ├ urls.py
    ├ wsgi.py
  ├ todo
    ├ migrations
      ├ __init__.py
      └ 0001_initial.py
    ├ __init__.py
    ├ admin.py
    ├ apps.py
    ├ models.py
    ├ serializers.py      # 追加
    ├ tests.py
    └ views.py
  ├ venv
    ├ Include
    ├ Lib
    ├ Scripts
    └ pyvenv.cfg
  ├ db.sqlite3
  └ manage.py
└ todo-frontend
  ├ node_modules
  ├ public
  ├ src
    ├ App.css
    ├ App.js
    ├ App.test.js
    ├ common
      └ api
        └ todo.js
    ├ components
      └ Top
        └ indez.jsx
    ├ configs
      └ Router.jsx
    ├ index.css
    ├ index.js
    ├ logo.svg
    ├ reportWebVitals.js
    └ setupTests.js
  ├ .gitignore
  ├ package-lock.json
  ├ package.json
  └ README.md

2-4) Viewの実装

todo/views.pyに以下を追記します。

/todo-backend/todo/views.py
- from django.shortcuts import render
+ from rest_framework import viewsets
+ from .models import Todo
+ from .serializers import TodoSerializer

+ class ToDoViewSet(viewsets.ModelViewSet):
+     queryset = Todo.objects.all()
+     serializer_class = TodoSerializer
+     filter_fields = ('name',)

2-5) ルーティングの実装

ルーティングはアプリケーションのディレクトリと、djangoプロジェクトのディレクトリ内のそれぞれのurls.pyに設定する必要があります。
初期状態ではアプリケーションのディレクトリにはurls.pyが存在しないため、自身で新たに作成して以下の設定をします。

/todo-backend/todo/urls.py
from rest_framework import routers
from .views import ToDoViewSet
from django.urls import path, include

router = routers.DefaultRouter()
router.register(r'todo', ToDoViewSet)

urlpatterns = [
    path('', include(router.urls)),
]
/todo-backend/project/urls.py
from django.contrib import admin
- from django.urls import path
+ from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
+     path('api/', include('todo.urls')),
]
ディレクトリ構成
root
├ todo-backend
  ├ project
    ├ __pychache__
    ├ __init__.py
    ├ asgi.py
    ├ settings.py
    ├ urls.py
    ├ wsgi.py
  ├ todo
    ├ migrations
      ├ __init__.py
      └ 0001_initial.py
    ├ __init__.py
    ├ admin.py
    ├ apps.py
    ├ models.py
    ├ serializers.py
    ├ tests.py
    ├ urls.py         # 追加
    └ views.py
  ├ venv
    ├ Include
    ├ Lib
    ├ Scripts
    └ pyvenv.cfg
  ├ db.sqlite3
  └ manage.py
└ todo-frontend
  ├ node_modules
  ├ public
  ├ src
    ├ App.css
    ├ App.js
    ├ App.test.js
    ├ common
      └ api
        └ todo.js
    ├ components
      └ Top
        └ indez.jsx
    ├ configs
      └ Router.jsx
    ├ index.css
    ├ index.js
    ├ logo.svg
    ├ reportWebVitals.js
    └ setupTests.js
  ├ .gitignore
  ├ package-lock.json
  ├ package.json
  └ README.md

これでAPIの準備はできました。
ブラウザから下記URLにアクセスすると以下のような画面が表示されます。

http://localhost:8000/api

image.png

下記URLからToDoを追加できるため、いくつか登録してみましょう。

http://localhost:8000/api/todo

image.png
image.png

3.フロントエンドのリクエスト処理実装

バックエンドを実装してAPIを用意できたため、フロントエンドの実装に戻ります。
useEffectを新たにインポートし、リクエスト処理を追加します。

/todo-frontend/src/components/Top/index.jsx
- import { useState } from 'react';
+ import { useState, useEffect } from 'react';

...

const Top = () => {

-   const [todoList, setTodoList] = useState(
-     [
-       {id: 1, name: '洗濯'},
-       {id: 2, name: '掃除'},
-       {id: 3, name:'食器洗い'}
-   ]);
+   const [todoList, setTodoList] = useState([]);
  const [todoName, setTodoName] = useState('');

+   useEffect(() => {
+     const url = new URL('http://localhost:8000/api/todo/')
+     fetch(url.href)
+     .then( res => res.json() )
+     .then( json => {
+       console.log(json)
+     })
+     .catch( error => {
+       console.log(error)
+     })
+   }, []);
  
  const handleSetTodo = (e) => {
    setTodoName(e.target.value);
  };

useEffectはマウント時とuseStateを使って定義した変数の値が更新されると、コールバック関数が実行されます。APIの処理をマウント時の一度だけ実行したいときは、第二引数に[]を入れます。

ここでブラウザの開発者コンソールを確認してみてください。(ChromeならばCtrl+Shift+Iを押下するとデベロッパーツールが開きます。)以下のようなエラーが出ているのではないでしょうか。

Access to fetch at 'http://localhost:8000/api/todo/' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

これはCORS(Cross-Origin Resource Sharing)という、別のドメインから別のドメインへアクセスがあった時にアクセスを制限するためのものです。

https://dotnsf.blog.jp/archives/1063051117.html

CORSはブラウザの制限ですので、ブラウザ以外からアクセスできるかを確認してみます。
以下のコマンドを実行すると値が取得できますね。

curl http://localhost:8000/api/todo/

# [{"id":1,"name":"買い出し","checked":false},{"id":2,"name":"勉強","checked":false},{"id":4,"name":"夕食","checked":false}]

4.CORS対策

CORSは、バックエンド側:Djangoで特定のアクセス元に対してアクセス許可を与えることで対策します。
既にインストール済みのdjango-cors-headersというライブラリで簡単に設定することができます。

/todo-backend/project/settings.py
INSTALLED_APPS = [
    ...
+     'corsheaders',
]

MIDDLEWARE = [
    ...
+     'corsheaders.middleware.CorsMiddleware',
]

+ # 許可するオリジン
+ CORS_ORIGIN_WHITELIST = [
+    'http://localhost:3000',
+ ]

もう一度Reactアプリの画面を更新して確認してみてください。
開発者コンソールにあった先ほどのエラーは無くなって値が取得できていることが分かります。

3.フロントエンドのリクエスト処理実装(act.2)

APIの実行処理を修正します。
また、同一ファイルに処理を書き増やすことは可読性が悪くなるため、リクエスト処理は別のファイルに移します。
先ほどcomponents/Top/index.jsxに追加したリクエスト処理を、src/common/api/todo.jsを作成して移動させます。

/todo-frontend/src/common/api/todo.js
const originUrl = new URL('http://localhost:8000/api/todo/');

export const getToDoList = (() => {
  const url = new URL('/api/todo/', originUrl);
  return new Promise( (resolve, reject) => {
    fetch(url.href)
    .then( res => res.json() )
    .then( json => resolve(json) )
    .catch( () => reject([]) );
  });
});
/todo-frontend/src/components/Top/index.jsx
...

+ import { getToDoList } from '../../common/api/todo';

...
  const [todoName, setTodoName] = useState('');

  useEffect(() => {
-     const url = new URL('http://localhost:8000/api/todo/')
-     fetch(url.href)
-     .then( res => res.json() )
-     .then( json => {
-       console.log(json)
-     })
-     .catch( error => {
-       console.log(error)
-     })
+     (async () => {
+       const list = await getToDoList();
+       setTodoList(list);
+     })();
  }, []);

  const handleSetTodo = (e) => {
    setTodoName(e.target.value);
  };

画面を確認すると、Djangoの管理画面で登録したToDoが表示されています。
image.png

ToDoの作成処理とチェック時の処理、削除処理を実装します。
まずは、APIの処理から。

/todo-frontend/src/common/api/todo.js
...

+ export const postCreateTodo = (name) => {
+   const url = new URL('/api/todo/', originUrl);
+   return new Promise( resolve => {
+     fetch(url.href, {
+       method: 'POST',
+       headers: {
+         'Accept': 'application/json',
+         'Content-Type': 'application/json'
+       },
+       body: JSON.stringify({
+         name: name
+       })
+     })
+     .then( res => res.json() )
+     .then( data => resolve(data) );
+   });
+ };
+ 
+ export const patchCheckTodo = ((id, check) => {
+   const url = new URL(`/api/todo/${id}/`, originUrl);
+   fetch(url.href, {
+     method: 'PATCH',
+     headers: {
+       'Content-Type': 'application/json'
+     },
+     body: JSON.stringify({
+       checked: check
+     })
+   });
+ });
+ 
+ export const deleteTodo = ((id) => {
+   const url = new URL(`/api/todo/${id}/`, originUrl);
+   fetch(url.href, { method: 'DELETE' });
+ });
  • postCreateTodo = 新しくToDoを作成する。
  • patchCheckTodo = チェックステータスを切り替えるのに使い、部分的にデータの更新するためPATCHアクションを実行する。
  • deleteTodo = ToDoの削除をする。

API処理ができましたので、コンポーネントのアクションに応じて呼び出す処理を実装します。

/todo-frontend/src/components/Top/index.jsx
- import { getToDoList } from '../../common/api/todo';
+ import { getToDoList, postCreateTodo, patchCheckTodo, deleteTodo } from '../../common/api/todo';

...

  useEffect(() => {
    (async () => {
      const list = await getToDoList();
      setTodoList(list);
    })();
  }, []);


  const handleSetTodo = (e) => {
    setTodoName(e.target.value);
  };

-   const handleCreate = () => {
+   const handleCreate = async () => {
    if (todoName === '' || todoList.some( value => todoName === value.name )) return;
-     setTodoList(todoList.concat({id: Math.floor(Math.random()*10000), name: todoName}));
+     await postCreateTodo(todoName);
+     setTodoList(await getToDoList());
  };

+   const handleCheck = (e) => {
+     const todoId = e.target.value;
+     const checked = e.target.checked;
+     const list = todoList.map( (value, index) => {
+       if (value.id.toString() === todoId) {
+         todoList[index].checked = checked;
+       }
+       return todoList[index];
+     });
+     setTodoList(list)
+     patchCheckTodo(todoId, checked);
+   }

  const handleDelete = (e) => {
    const todoId = e.currentTarget.dataset.id;
    const list = todoList.filter( value => value['id'].toString() !== todoId);
    setTodoList(list);
+     deleteTodo(todoId);
  };

...

  return (
  ...
            <Box key={index} display="flex" justifyContent="space-between" mb={1}>
+               <FormControlLabel
+                 control={
+                   <Checkbox value={todo.id} onChange={handleCheck} checked={todo.checked ? true : false} />
+                 }
+                 label={todo.name}
+               />
              <Button variant="outlined" data-id={todo.id} onClick={handleDelete}>削除</Button>
            </Box>
  ...

handleCreateでsetTodoList(todoList.concat({id: Math.floor(Math.random()*10000), name: todo}));としていましたが、保存された値が返ってくるためその値をtodoListに入れています。
返ってくるデータのidはデータベースへ保存する際の一意な値となっているため、仮で入れていたMath.floor(Math.random()*10000)はここで不要となりました。

ToDoリストはチェックが可能なので、handleCheckとして新たにチェック処理を実装しています。

これでToDoアプリは完成です!

image.png
image.png

※「ストレッチ」と登録したあとも入力欄に「ストレッチ」と残ってしまうバグを修正したいのですが、対処法が分かる方はご教示くださいませ。


今回のソースコードはGitHubにコミット済みです。

https://github.com/whitecat-22/drf_react_spa_frontend.git

https://github.com/whitecat-22/drf_react_spa_backend.git


参考

https://note.com/uichiyy/n/nadc6f56b01de


(編集後記)

Reactを用いることで、DjangoのMTVのT(Template)での実装よりもフロントエンドの表現力が増し、かつ、手軽にWebアプリケーションのUI開発が可能となること、Webページ内で操作が完結できる使いやすさは、大いに魅力です。
JSXによる宣言的なViewやコンポーネントベースのUI構築といった特徴を生かせば、開発の生産性向上も図れることから、人気のフレームワークであることも理解できました。

今後はJavaScriptの実装力を上げて、よりインタラクティブなUI開発を進めたいですし、スマートフォンやタブレットでの表示に対応したWebアプリや、React Nativeへ発展させてネイティブアプリも開発したいと思います。
今回の実装を通じて、自身の持ち合わせるスキルに対して、Dart言語を用いるFlutterの学習コストがより高いことを感じつつも、マルチプラットフォームのアプリケーション実装に向けて、引き続き双方の学習をすすめていきたいと考えます。

https://flutter.dev/

Discussion