React + TypeScript + Django REST frameworkでToDo管理アプリを作ってみた

先日、大岡由佳さんの「りあクト! 第3.1版 (2020年12月26日 初版第1刷発行)」を読みました。書籍では理由や経緯などが書かれており、とてもためになりました。ありがとうございました。

 
そこで、Reactの理解を深めるために何か作ろうと考え、よくあるToDo管理アプリを作ってみたことから、その時のメモを残します。

 

目次

 

環境

  • React 17.0.2
  • TypeScript 4.1.2
  • Python 3.9.5
  • Django 3.2.2
  • Django REST framework 3.12.4
  • PyCharm Professional 2021.1.1

 
今回は各段階を追ってアプリを作成していきます。

アプリのイメージははこんな感じで、段階ごとの見た目はほとんど変わりません。

f:id:thinkAmi:20210510223401p:plain
ToDo管理アプリ

 

やることとやらないこと

やること
  • Reactまわり
    • TypeScriptで書く
    • React Router の5系で書く
    • Hooksを使う
  • Django REST frameworkまわり
    • ModelViewSetを使ってお手軽に書く

 

やらないこと

React & TypeScriptに慣れるため、以下をやらないことにしました。

 

環境構築

Python

最初のうちは Django REST frameworkを使いませんが、忘れないよう venv で環境を作っておきます。

% python --version
Python 3.9.5

% python -m venv env

 

CRAによる、React環境構築
% npx create-react-app frontend --template typescript

We suggest that you begin by typing:

  cd frontend
  yarn start

Happy hacking!

 

ESLint

CRAでESLintはインストールされているため、init します。

% yarn eslint --init

? How would you like to use ESLint? … 
❯ To check syntax, find problems, and enforce code style

? What type of modules does your project use? … 
❯ JavaScript modules (import/export)

? Which framework does your project use? … 
❯ React

? Does your project use TypeScript? › Yes

? Where does your code run? …  (Press <space> to select, <a> to toggle all, <i> to invert selection)
✔ Browser

? How would you like to define a style for your project? … 
❯ Use a popular style guide

? Which style guide do you want to follow? … 
❯ Airbnb: https://github.com/airbnb/javascript

? What format do you want your config file to be in? … 
❯ JavaScript

Checking peerDependencies of eslint-config-airbnb@latest
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

eslint-plugin-react@^7.21.5 @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint@^5.16.0 || ^6.8.0 || ^7.2.0 eslint-plugin-import@^2.22.1 eslint-plugin-jsx-a11y@^6.4.1 eslint-plugin-react-hooks@^4 || ^3 || ^2.3.0 || ^1.7.0 @typescript-eslint/parser@latest

? Would you like to install them now with npm? › No

 
エラーが出ました。

ESLint couldn't find the config "airbnb" to extend from. Please check that the name of the config is correct.

The config "airbnb" was referenced from the config file in "".

If you still have problems, please stop by https://eslint.org/chat/help to chat with the team.

error Command failed with exit code 2.

 
足りていないものを、yarnでインストールします。

% yarn add eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest eslint-config-airbnb@latest eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react-hooks @typescript-eslint/parser@latest

info All dependencies
├─ @typescript-eslint/eslint-plugin@4.22.1
├─ @typescript-eslint/parser@4.22.1
├─ eslint-config-airbnb-base@14.2.1
├─ eslint-config-airbnb@18.2.1
├─ eslint-plugin-import@2.22.1
├─ eslint-plugin-jsx-a11y@6.4.1
├─ eslint-plugin-react-hooks@4.2.0
└─ eslint-plugin-react@7.23.2

 
設定ファイルを追加・更新します。

全部載せると長いので、リンクをはっておくだけにします。

 

Prettier

インストールします。

% yarn add -D prettier eslint-config-prettier

info All dependencies
├─ eslint-config-prettier@8.3.0
└─ prettier@2.2.1

»  TypeSync v0.8.0
✔  2 new typings added.

📦 frontend — package.json (2 new typings added, 0 unused typings removed)
├─ + @types/prettier
└─ + @types/testing-library__jest-dom

✨  Go ahead and run npm install or yarn to install the packages that were added.


% yarn

success Saved lockfile.

 
設定ファイルを追加します。

 
ESLintとPrettierが衝突していないか確認します。

% npx eslint-config-prettier 'src/**/*.{js,jsx,ts,tsx}'
No rules that are unnecessary or conflict with Prettier were found.

 

package.jsonのscriptにeslintやprettierを追加します。

"fix": "npm run -s format && npm run -s lint:fix",
"format": "prettier --write --loglevel=warn 'src/**/*.{js,jsx,ts,tsx,gql,graphql,json}'",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}",
"lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'",
"lint:conflict": "eslint --print-config .eslintrc.js | eslint-config-prettier-check",
"preinstall": "typesync"

 

PyCharmの設定

今回はProfessional版を使うので、prettier pluginを追加します。

その後、以下の設定を行います。

 

ESLintの設定

Languages & Frameworks > JavaScript > Code Quality Tools > ESLint

[x] Automatic ESLint configuration

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx,html,vue}

[ ] Run eslint --fix on save

 

Prettierの設定

Languages & Frameworks > JavaScript > Prettier

Prettier package: <プロジェクトの node_modules にある prettierを指定>

Run for files: (デフォルトのまま)
{**/*,*}.{js,ts,jsx,tsx}

[x] On code reformat
[x] ON save

 
Prettierの設定ができているかは、index.tsx

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root'),
);

ワンライナーにした状態で保存した時に、上記のように修正されればOKです。

 

huskyとlint-staged

インストールします。

% yarn add -D husky lint-staged

 
package.jsondevDependencies の下に追加します。

"husky": {
  "hooks": {
    "pre-commit": "lint-staged"
  }
},
"lint-staged": {
  "src/**/*.{js,jsx,ts,tsx}": [
    "prettier --write --loglevel=warn",
    "eslint --fix"
  ],
  "src/**/*.{json}": [
    "prettier --write --loglevel=warn"
  ]
}

 

ディレクトリ構成

今回は段階を追ってアプリを作成していくため、

  • srcの下に component を作る
  • componentの下に、 v1 のような各段階ごとのcomponentを入れるディレクトリを作る

のようなディレクトリ構造としました。

 

第1段階:入力フォームとリスト表示を1つのコンポーネントに入れたバージョン

まずは入力フォームとリスト表示を1つのコンポーネントに入れてみます。

React Routerと使用するバージョンについて

各段階でURLを変えるため、React Routerを使います。

りあクト!では v5 と v6 の話がありましたが、今のところまだ v6 はリリースされていないため、今回は v5 系を使います。

 

インストール
% yarn add react-router react-router-dom

% yarn

 

App.tsx の実装

App.tsx にReact Routerの実装を行います。

今回は

な書き方とします。

import React, { VFC } from 'react';
import './App.css';
import { Switch, Route, Redirect } from 'react-router';
import V1Home from 'components/v1/V1Home';
import Home from './components/Home';

const App: VFC = () => (
  <div className="container">
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>

      <Route exact path="/v1">
        <V1Home />
      </Route>

      <Redirect to="/" />
    </Switch>
  </div>
);

export default App;

 

V1Home.tsx

React Routerから呼ばれるコンポーネントです。この中で V1FormList コンポーネントを呼び出しています。

import { VFC } from 'react';
import V1FormList from './V1FormList';

const V1Home: VFC = () => (
  <>
    <h1>タスクリスト</h1>
    <V1FormList />
  </>
);

export default V1Home;

 

V1FormList.tsx

全体像はこんな感じです。以降で詳しく見ていきます。

import { FormEvent, useEffect, useRef, useState, VFC } from 'react';

type Task = {
  id: number;
  content: string;
};

const V1FormList: VFC = () => {
  const [content, setContent] = useState('');
  const [tasks, setTasks] = useState<Task[]>([]);

  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>

      <ul>
        {tasks.map((task) => (
          <li key={task.id}>{task.content}</li>
        ))}
      </ul>
    </>
  );
};

export default V1FormList;

 

State Hookの利用

上記では、input要素で入力した値を保管するために State Hook を使っています。
ステートフックの利用法 – React

 
まずは入力値を入れておく State Hookを用意します。

const [content, setContent] = useState('');

 
次に、入力した値はリストとして表示したいため、リスト表示用の State Hook も用意します。

Stateの型は上記の content を配列で持つ string[] でも良いのですが、リスト表示する時の key を与えたいため、型を用意します。
リストと key – React

type Task = {
  id: number;
  content: string;
};

この型を使った State を用意します。

const [tasks, setTasks] = useState<Task[]>([]);

 
フォームで登録ボタンを押した時に、リスト表示用の State に値を追加する関数を用意します。

const addTask = (e: FormEvent) => {
  e.preventDefault();
  setTasks([...tasks, { id: tasks.length, content }]);
  setContent('');
};

ここで、addTask関数の引数は FormEvent型 を想定しています。
Forms and Events | React TypeScript Cheatsheets

また、 preventDefault() を使ってデフォルトのイベントをキャンセルし、バックエンドにリクエストが飛ばないようにします。

 
また、配列のStateでは、Array.pushが使えません。

そのため、スプレッド構文を使って、Stateに値を追加しています。

setTasks([...tasks, { id: tasks.length, content }]);

 
最後の setContent(''); は、リスト表示用Stateに値を入れたら画面のinput要素をクリアするために用意しています。

 
あとは

  • formの onSubmit に、追加用関数 addTask を割り当て
  • inputの onChange に、content Stateの更新処理を追加
  • inputの value に、 onChangeで更新した値を割り当て
  • ulの中で、 tasks Stateのデータを元に表示

とします。

return (
  <>
    <form onSubmit={addTask}>
      <input
        type="text"
        name="task"
        onChange={(e) => setContent(e.target.value)}
        value={content}
      />
      <button type="submit">登録</button>
    </form>

    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

 

フォーカスの指定

ここで、「登録ボタンを押した後、input要素にフォーカスを戻したい」という要望がある場合、どのように実装すればよいかを調べてみました。

りあクト!Ⅱの p192 より、Reactでは、 useRefuseEffect を使うと良さそうでした。

const taskRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
  taskRef.current?.focus();
});

 
ちなみに、型定義や useRefの引数から null を抜くと、以下のエラーとなりました。

TS2322: Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'LegacyRef<HTMLInputElement>| undefined'.
Type 'MutableRefObject<HTMLInputElement | undefined>' is not assignable to type 'RefObject<HTMLInputElement>'.
Types of property 'current' are incompatible.
Type 'HTMLInputElement | undefined' is not assignable to type 'HTMLInputElement | null'.
Type 'undefined' is not assignable to type 'HTMLInputElement | null'.

 
この taskRef を input に紐付けます。

<input
  ..
  ref={taskRef}
  ..
/>

 

動作確認

以下でReactが起動しますので、タスクを色々いれてみます。

% yarn start

 

これで第1段階が完成しました。コミットは こちら です。

 

第2段階:FormとListのコンポーネントを分離する

第1段階では ListForm.tsx の中に Form と List が含まれていました。

次はそれらを分離してみます。

 

Stateをどこに持たせるか

分離するにあたり、 State をどこに持たせるかを考えます。

content は Form でしか使っていませんが、 tasks は Form と List の両方で使っています。

Reactの流儀によると、

共通の親コンポーネントか、その階層構造でさらに上位の別のコンポーネントが state を持っているべきである

https://ja.reactjs.org/docs/thinking-in-react.html#step-4-identify-where-your-state-should-live

とのことなので、今回の共通の親である V2Home.tsxtasks Stateを持たせます。

const V2Home: VFC = () => {
  const [tasks, setTasks] = useState<Task[]>([]);

  return (
    <>
      <h1>タスクリスト (v2)</h1>
      <V2Form tasks={tasks} setTasks={setTasks} />
      <V2List tasks={tasks} />
    </>
  );
};

 

型定義

ここで、 Task 型はどのコンポーネントでも使うので、 src/components/Task.ts に型定義を置いておきます。

export type Task = {
  id: number;
  content: string;
};

なお、Task typeをV2Home.tsxに持たせ、

// Home
import V2Form from './V2Form';

// Form
import { Task } from './V2Home';

のように import したところ

ESLint: Dependency cycle detected.(import/no-cycle)

というESLintのエラーが出ました。

 
あとは、FormとListを分離します。

 

V2Form.tsx

このコンポーネントがpropsで受け取る型は

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

とします。

なお、setTasksの型 Dispatch<SetStateAction<Task[]>> については、PyCharmで提案されたものをそのまま入れました。

Formの全体像はこちら。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V2Form: VFC<Props> = ({ tasks, setTasks }) => {
  const [content, setContent] = useState('');
  const taskRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    taskRef.current?.focus();
  });

  const addTask = (e: FormEvent) => {
    e.preventDefault();
    setTasks([...tasks, { id: tasks.length, content }]);
    setContent('');
  };

  return (
    <>
      <form onSubmit={addTask}>
        <input
          type="text"
          name="task"
          ref={taskRef}
          onChange={(e) => setContent(e.target.value)}
          value={content}
        />
        <button type="submit">登録</button>
      </form>
    </>
  );
};

 

V2List.tsx

Listも、分離したもの + propsの型定義を用意します。

type Props = {
  tasks: Task[];
};

const V2List: VFC<Props> = ({ tasks }) => (
  <>
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>{task.content}</li>
      ))}
    </ul>
  </>
);

これで第2段階が完成しました。コミットは こちら です。

 

第3段階:Django REST framework製バックエンドと連携する

ここまでは入力した値は State にしか保存していませんでした。そのため、リロードしたら消えてしまいます。

そこで次はDBを持つバックエンドを用意して、データを永続化します。

 

Django REST framework アプリの作成

今回はReactの習得がメインなので、バックエンドは慣れている Django REST framework (以降、DRF) で作ります。

今回はCRUDができれば良いので、ModelViewSetを使ってお手軽に作ります。

詳しい実装は省略しますが、気になる場合は以下の書籍を参考にしてください。
現場で使える Django REST Framework の教科書(第2版) - あきよこブログ(akiyoko blog) - BOOTH

 
DRFをインストールします。

(env) % pip install django djangorestframework
...
Successfully installed asgiref-3.3.4 django-3.2.2 djangorestframework-3.12.4 pytz-2021.1 sqlparse-0.4.1

 
Djangoプロジェクトとアプリを作ります。

(env) % django-admin startproject config .
(env) % python manage.py startapp task
(env) % python manage.py startapp apiv1

 

なお、DRFとReactを連携する際、DRFとReactが

  • Reactはポート3000で起動
  • DRFはポート8000で起動

するため、別オリジンとなります。

そこで、 django-cors-headers を追加でインストールと設定を行い、ReactからのDRFアクセスを許可します。
adamchainz/django-cors-headers: Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS)

(env) % pip install django-cors-headers

 
ちなみに、手元にあるDRFの教科書(2019/9/22 v1.0.0)で使っていた 3.1.0では、別オリジンの設定名は CORS_ORIGIN_WHITELIST でしたが、今回使った 3.7.0 では CORS_ALLOWED_ORIGINS に変わっていました。

CORS_ALLOWED_ORIGINS = (
    'http://localhost:3000',
    'http://127.0.0.1:3000',
)

 
 
PyCharmにDjangoを認識させます。

# Languages & Frameworks > Django

[x] Enable Django support

Django project root : <root dir>
Settings: config/settings.py
Manage script: manage.py

 

準備ができたため、 このコミットのようなアプリを作ります。

アプリを作り終えたらマイグレーションします。

(env) % python manage.py makemigrations

(env) % python manage.py migrate

 
あとはDRFを起動し、Browsable APIhttp://localhost:8000/api/v1/tasks/ にアクセス、データの登録などができることを確認します。

 

axiosのインストール

ReactからDRFへリクエストを飛ばすために、axiosをインストールします。

% yarn add axios

% yarn

 

FormコンポーネントDRFと連携する

連携するため、

  • 初期値のロード
  • 登録ボタンを押すと、DRF側へリクエストが飛んで保存

の2つを実装します。

 

初期値のロード

初期値をロードする Effect Hook を用意します。
React Hooksでデータを取得する方法 - Qiita

useEffect(() => {
  console.log('called!');
  const fetchData = async () => {
    const response: AxiosResponse<ApiGetResponse> = await axios.get(API_URL);
    setTasks(response.data);
  };

  void fetchData();
}, [setTasks]);

 

登録ボタンを押すと、DRF側へリクエストが飛んで保存

ボタンを押したらリクエストを飛ばすため、addTask関数の中に処理を追加します。
reactjs - How to POST request using axios with React Hooks? - Stack Overflow

axiosを使って非同期でリクエストを飛ばすため、 async/awaitも使います。

const addTask = async (e: FormEvent) => {
  e.preventDefault();

  const requestData = { content };
  try {
    const response: AxiosResponse<ApiPostResponse> = await axios.post(
      API_URL,
      requestData,
    );

    setTasks([...tasks, { id: response.data.id, content }]);
    setContent('');
  } catch (error) {
    alert('エラーでした');
  }
};

 
なお、axiosを使う時の型ですが、AxiosResponseは

import axios, { AxiosResponse } from 'axios';

と、axios から importします。

また、axiosのレスポンスのdataの型は、

type ApiGetResponse = {
  id: number;
  content: string;
}[];

type ApiPostResponse = {
  id: number;
  content: string;
};

と用意し、

const response: AxiosResponse<ApiPostResponse> = await axios.post()

のように渡します。

 
これで第3段階も完成です。コミットは3つに分かれています。

 

第4段階:削除ボタンを付ける

今までは作成するだけだったので、削除ボタンを付けてみます。

Listの方に付けるため、まずは削除する関数を用意します。

removeTask関数の中では、

  • axiosで削除
  • tasks Stateに、引数で渡された task.id と一致しないものだけセット

します。

const removeTask = async (taskId: number) => {
  try {
    await axios.delete(`${API_URL}${taskId}/`);
    setTasks(tasks.filter((task) => task.id !== taskId));
  } catch (error) {
    alert('エラーでした');
  }
};

 
そして、Listのコンポーネントの中でも setTasks Hookを使うようになったため、型定義とpropsを追加します。

type Props = {
  tasks: Task[];
  setTasks: Dispatch<SetStateAction<Task[]>>;
};

const V4List: VFC<Props> = ({ tasks, setTasks }) => { ... }

 
これで完成です。コミットは こちら

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf

また、今回実装した機能がまとまっているプルリクはこちらです。
https://github.com/thinkAmi-sandbox/todolist_by_react_ts_drf/pull/1