🔰

React Hooks と TypeScript で簡単 TODO アプリ

53 min read

2021/11/23: この記事の内容は、以下の書籍の無料公開部分と同じです。

https://zenn.dev/sprout2000/books/76a279bb90c3f3

はじめに

対象とする読者

以下のような人を読者として想定しています。

  • ある程度 JavaScript を習得している人
  • React 公式チュートリアルを終えたものの、次のステップを探しているような人
  • Node.js をインストール済みである人
  • Git Bash もしくは何らかの UNIX シェルの操作をある程度習得している人

https://jsprimer.net/

https://ja.reactjs.org/tutorial/tutorial.html

https://nodejs.org/ja/

https://git-scm.com/

コードエディタには、 Visual Studio Code (以下、VSCode)を利用します。VSCode に備わる機能の利用を前提とした記述もありますので、インストールしておくことをおすすめします。

https://code.visualstudio.com/

目標とする Todo アプリ

  • タスク (Todo) を既済・未済・削除済みなどの状態によってフィルタリングできる
  • 登録済みタスクを編集できる
  • 削除済みアイテムを「ごみ箱」フィルタから完全に削除できる

他のおすすめのドキュメント

https://ja.reactjs.org/tutorial/tutorial.html

https://ja.reactjs.org/docs/hooks-intro.html

https://typescript-jp.gitbook.io/deep-dive/

https://zenn.dev/sprout2000/articles/36346c26f98e6e

開発環境の準備

Vite.js で React プロジェクトを作成する

Vite.js(以下、Vite)を利用して、todo という名前の React + TypeScript のプロジェクトを作成します。

https://ja.vitejs.dev/
zsh
# Node.js に同梱されている npm コマンドを利用する場合
% npm init vite

# パッケージマネージャーに Yarn を利用している場合
% yarn create vite

以下のスクリーンショットでは Node.js デフォルトの npm コマンドを利用しています。

  1. todo と入力します。


プロジェクトに名前をつける

  1. react を選択します。


フレームワークの選択

  1. react-ts を選択します。


JavsScript または TypeScript の選択

zsh
% npm init vite

✔ Project name: … todo
✔ Select a framework: › react
✔ Select a variant: › react-ts

Scaffolding project in /Users/zenn/Downloads/todo...

Done. Now run:

  cd todo
  npm install
  npm run dev

Vite の指示に従い、さっそくこのプロジェクトを起動してみましょう。

zsh
% cd todo
% npm install
% npm run dev

ブラウザで http://localhost:3000 にアクセスすると React アプリが表示されます。

このプロジェクト(開発用ローカルサーバー)を停止するには、ターミナルで Ctrl+C を打鍵してください。

zsh
  vite v2.6.7 dev server running at:

  > Local: http://localhost:3000/
  > Network: use `--host` to expose

  ready in 199ms.

^C   # <-- Ctrl+C を入力

% 🔲

React デベロッパーツールの準備

Google ChromeMozilla Firefox、または Microsoft Edge には React Developer Tools という拡張機能がそれぞれ用意されています。

https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi

これは、React コンポーネントの状態をブラウザの開発者ツール画面に表示してくれる拡張機能です。必ずインストールしておきましょう。

開発者ツールを表示するには、キーボードから Ctrl + Shift + I を打鍵します( Windows 版 Chrome の場合)。 Components タブからコンポーネント名(ここでは App)を選択すると React コンポーネントの状態を確認することができます。

ホットリロード機能の確認

VSCode(もしくは何らかのコードエディタ)で todo フォルダを開き、src/App.tsx を編集してみましょう。

src/App.tsx
  function App() {
    const [count, setCount] = useState(0)

    return (
      <div className="App">
        <header className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
 -         <p>Hello Vite + React!</p>
+         <p>Hello Todo App!</p>
          <p>
            <button type="button" onClick={() => setCount((count) => count + 1)}>
              count is: {count}
            </button>
          </p>

  // ~ snip ~

ブラウザ画面へ変更が自動的に反映されていることが確認できると思います。


中央のメッセージが 'Hello Todo App!' に変わっている

React コンポーネントの作成

todo(タスク)を入力するためのフォームを持った、関数コンポーネントを作成するところから始めましょう。

https://ja.reactjs.org/docs/components-and-props.html

src ディレクトリ内の main.tsxApp.tsx の2つのファイルを以下のファイルへ置き換えてください。

src/main.tsx
import ReactDOM from 'react-dom';
import { App } from './App';

ReactDOM.render(<App />, document.getElementById('root'));
src/App.tsx
export const App = () => {
  return (
    <div>
      <form onSubmit={(e) => e.preventDefault()}>
        <input type="text" value="" onChange={(e) => e.preventDefault()} />
        <input
          type="submit"
          value="追加"
          onSubmit={(e) => e.preventDefault()}
        />
      </form>
    </div>
  );
};

以降、この関数コンポーネント App をベースとして Todo アプリを作成していきます。

2021-04-24-174116.png

いまのところ onSubmitonChange などのイベントリスナーでは、イベントが発生しても preventDefault() してしまっているので特に何も起きません。

https://developer.mozilla.org/ja/docs/Web/API/Event/preventDefault

フォームに入力された文字列を状態 (=state) として保持する

useState フックの構文

構文
const [foo, setFoo] = React.useState('bar');
  • useState:
    • 引数となるのはステートの初期値です
    • 現在のステート foo と、それを更新するための関数 setFoo とをペアにして返します
  • foo: 現在のステートの値です
  • setFoo: ステートを更新するメソッドです

https://ja.reactjs.org/docs/hooks-state.html

text ステートを作成

src/App.tsx
// React から useState フックをインポート
import { useState } from 'react';

export const App = () => {
  /**
   * text = ステートの値
   * setText = ステートの値を更新するメソッド
   * useState の引数 = ステートの初期値 (=空の文字列)
   */
  const [text, setText] = useState('');

  return (
    <div>
      <form onSubmit={(e) => e.preventDefault()}>
        {/*
          入力中テキストの値を text ステートが
          持っているのでそれを value として表示

          onChange イベント(=入力テキストの変化)を
          text ステートに反映する
         */}
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
        />
        <input
          type="submit"
          value="追加"
          onSubmit={(e) => e.preventDefault()}
        />
      </form>
    </div>
  );
};

2021-04-24 17.54.47 (1).png

Todo(タスク)の仕様を考える (その 1)

ひとつの todo をオブジェクトとすると、そのオブジェクトにはタスクの内容を保持するプロパティ(変数)が必要となります。ここでは、これを value プロパティとします。

入力フォームに入力されたテキスト文字列が代入されるので、この value プロパティは string 型 となります。

これから作成される複数の todo のひな型として Todo 型オブジェクト型エイリアスを定義しましょう。

App.tsx
type Todo = {
  value: string;
};

export const App = () => {

https://typescript-jp.gitbook.io/deep-dive/type-system#eiriasutype-alias

ステートとして保持しておくタスクたち(todos 複数)は Todo 型オブジェクトの配列 となります。

App.tsx
export const App = () => {
  const [text, setText] = useState('');
  // 追加
  const [todos, setTodos] = useState<Todo[]>([]);

  return (

useState<> の中に型を指定しておくと、これと型が異なる値をステートに代入することができなくなるため、ステートの型安全性が常に保証されます。

https://typescript-jp.gitbook.io/deep-dive/getting-started/why-typescript

配列ステートの操作には要注意

前章で作成した Todos ステートTodo 型オブジェクトの配列 ですが、配列のステートを操作しなければならない場合、その配列を直接触ってはいけません
その理由は、「ステートのイミュータビリティimmutability, 不変性)が保持できなくなる」からですが、これを詳しく見ていきましょう。

イミュータブルな操作とは?

イミュータブルな操作とは、その操作の対象となった元の値を不変(=イミュータブル)に保つ操作のことです。

例 1
const array1 = [0, 1, 2];
const array2 = [0, 1, 2];

array1.push(3);
[...array2, 3];

上の例での下 2 行では、どちらもそれぞれの元の配列に 3 という要素を追加しています。
では、元の配列の値はどうなったでしょうか?

結果
console.table(array1);
0
1
2
3

console.table(array2);
0
1
2

Array.push メソッドが元の配列を 変更(=ミューテート) してしまったのに対し、スプレッド構文を使った要素の追加では元の配列の イミュータビリティ(=不変性) が保たれています。

ここでの Array.push メソッドが「ミュータブルな操作」、スプレッド構文が「イミュータブルな操作」です。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/push

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

なぜ React ではイミュータブルな操作が必要なのか?

では、なぜ React ではイミュータブルな操作が必要とされるのでしょうか?
それは、React がコンポーネントの変化をオブジェクトの 同一性(差分) チェックで検知しているためです。

ミュータブルな操作をしてしまうとコピー元の情報も変更されてしまうため、変更前と変更後の差分を React が検知できなくなってしまいます。

一方、イミュータブルな操作では変更前と変更後の情報をそれぞれ参照しているので、React は差分を検知することができます。

ここでの例で言うと、todos ステートへの以下のような操作はいけません。

src/App.tsx
// ❌ Bad code
setTodos(todos.push({ value: 'new task' }));

なぜなら、Array.prototype.push()Array.prototype.unshift() は破壊的メソッドなので todos ステートを直接ミューテート(mutate, 書き換え)してしまうからです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/unshift

https://qiita.com/Shokorep/items/929e2e66908eaa915286

配列のステートを操作する場合には、 いったんそのコピーに対して変更を加え、その変更後のコピーでステートを更新します。

const todos = [{ value: '最初のタスク' }];

// todos ステート配列をコピー
const newTodos = todos.slice();

// コピーした配列へ Todo 型オブジェクトの要素を追加
newTodos.unshift({ value: '新しいタスク' });

// それぞれの配列の内容を確認
console.log('=== old todos ===');
todos.map((todo) => console.log(`value: ${todo.value}`));

/**
 *
 * 結果:
 * === old todos ===
 * value: 最初のタスク
 *
 **/

console.log('=== new todos ===');
newTodos.map((todo) => console.log(`value: ${todo.value}`));

/**
 *
 * 結果:
 * === new todos ===
 * value: 新しいタスク
 * value: 最初のタスク
 *
 * 元の配列 (= todos ) に影響を与えることなく、
 * コピーした配列 (= newTodos ) へ要素が追加されている
 *
 */

// 新しい配列で todos ステートを更新
setTodos(newTodos);

こうすることで元の配列(=更新前のステート)と新しいステートの差分を React が検知できるようになります。

参考記事

https://ja.reactjs.org/tutorial/tutorial.html#why-immutability-is-important

https://qiita.com/sh-suzuki0301/items/597bdbf17253feb5f55b

ステートを更新するコールバック関数を作成する

コールバック関数の作成

それでは todos ステートを更新(=新しいタスクの追加)していきましょう。
ステートを更新するコールバック関数を作成します。

src/App.tsx
  const [todos, setTodos] = useState<Todo[]>([]);

  // todos ステートを更新する関数
  const handleOnSubmit = () => {
    // 何も入力されていなかったらリターン
    if (!text) return;

    // 新しい Todo を作成
    const newTodo: Todo = {
      value: text,
    };

    /**
     * スプレッド構文を用いて todos ステートのコピーへ newTodo を追加する
     * 以下と同義
     *
     * const oldTodos = todos.slice();
     * oldTodos.unshift(newTodo);
     * setTodos(oldTodos);
     *
     **/
    setTodos([newTodo, ...todos]);
    // フォームへの入力をクリアする
    setText('');
  };

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

コールバック関数をイベントに割り当てる

上のコールバック関数を onSubmit イベントへ紐付けましょう。

注意点:

  • コールバックとして渡すのは () => hoge() もしくは hoge関数そのものです
  • hoge() のみだと即時に実行されてしまうので用をなしません

https://sbfl.net/blog/2019/02/08/javascript-callback-func/

<form> タグの中でいったん e.preventDefault() しているのは Enter キー打鍵でページそのものがリロードされてしまうのを防ぐためです。

src/App.tsx
  return (
    <div>
     {/* コールバックとして () => handleOnSubmit() を渡す */}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
        />
        {/* 上に同じ */}
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
     </form>
   </div>

onSubmit イベントが発火するとコールバック関数が実行され、todos ステートを更新(=新しいタスクを追加)します。
フォームへ入力して submit(Enter キー打鍵)すれば、ステート (todos) が更新されていることを開発者ツールで確認できます。

2021-04-25 7.33.48 (1).png

text ステート向けのコールバック関数も用意する

上の例で要領を得ましたので、text ステート についても JSX の中で直接 setText していた部分をコールバック関数 handleOnChange() として書き出しましょう。

コールバック関数として書き出すことで、のちにコンポーネント間での props の受け渡しが容易になったことに気が付きます。

src/App.tsx
  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };
src/App.tsx
<input type="text" value={text} onChange={(e) => handleOnChange(e)} />

イベントの型を調べる

イベントの型がわからない時は、VSCode であればイベント上でマウスカーソルを hover させるとポップアップが表示されます。

この章のソースコード全文

App.tsx
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
};

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input type="text" value={text} onChange={(e) => handleOnChange(e)} />
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
      </form>
    </div>
  );
};

todos ステートを展開してページに表示する

todos ステート配列を Array.map() メソッドで展開する

todos ステートを展開し、タスク一覧としてページに表示します。
具体的には、todos (=配列) を非破壊メソッドである Array.prototype.map() を使って <li></li> タグへ展開します。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

https://zenn.dev/kojinishimura/articles/78450a1fe35664
src/App.tsx
    <div>
      <form onSubmit={(e) => handleOnSubmit(e)}>
        <input
          type="text"
          value={text}
          onChange={(e) => setText(e.target.value)}
        />
        <input type="submit" value="追加" onSubmit={(e) => handleOnSubmit(e)} />
      </form>
      <ul>
        {todos.map((todo) => {
          return <li>{todo.value}</li>;
        })}
      </ul>
    </div>

ただし、これだけでは各 <li>key プロパティが設定されていないため、以下のような警告が表示されてしまいます。

チュートリアル:React の導入 - key を選ぶ(公式)

2021-04-25 7.45.35.png

2021-04-25 7.44.18.png

リストをレンダーするときの key の重要性

なぜリストの各項目に key プロパティが必要となるのでしょうか?

React はリストをレンダーする際、どのアイテムが変更になったのか特定できる必要があります。リストのアイテムは追加された可能性も、削除された可能性も、並び替えられた可能性も、中身自体が変更になった可能性もあるからです。

変更・追加・削除・並び替えを検知するためには、リストの各項目を特定する一意な識別子が必要です。

この一意な識別子こそが key プロパティであり、上の警告は『各項目を特定することができないため、リストに変更が加えられても正しく再レンダーできない可能性があります』という意味で表示されているのです。

次章では、この key プロパティを各項目へ与えるため、Todo 型オブジェクト の仕様について再考します。

Todo(タスク) の仕様を考える (その 2)

前章で見た通り、todos ステート配列をリストとして展開するためには、配列の各要素へその識別子を持たせる必要があります。

配列の各要素、つまり Todo 型のタスクそれぞれに一意な key を持たせる必要が生じたため、Todo 型そのものを拡張しなければなりません。

ここでは、id プロパティとして一意な数字 (number 型) を持たせることにします。
また、一意であるはずの識別子が書き換えられてはならないため、readonly(読み取り専用) のプロパティとします。

src/App.tsx
  type Todo = {
    value: string;
+   readonly id: number;
  };

https://typescript-jp.gitbook.io/deep-dive/type-system/readonly

Todo 型オブジェクトには id プロパティの指定が必須となったため、handleOnSubmit() メソッドを更新しなければいけません。

src/App.tsx
const handleOnSubmit = (
    e: React.FormEvent<HTMLFormElement | HTMLInputElement>
  ) => {
    e.preventDefault();
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      /**
      * Todo 型オブジェクトの型定義が更新されたため、
      * number 型の id プロパティの存在が必須になった
      */
      id: new Date().getTime(),
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

これでそれぞれのタスクが一意なプロパティを持つようになったので、これを key プロパティへ適用しましょう。
<li></li> タグに key (=id) を付加します。

src/App.tsx
      <ul>
        {todos.map((todo) => {
          return <li key={todo.id}>{todo.value}</li>;
        })}
      </ul>

2021-04-25 10.01.09.png

key は特別なプロパティであり、React によって予約されています。

https://zenn.dev/luvmini511/articles/f7b22d93e9c182
この章のソースコード全文
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
};

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input type="text" value={text} onChange={(e) => handleOnChange(e)} />
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
      </form>
      <ul>
        {todos.map((todo) => {
          return <li key={todo.id}>{todo.value}</li>;
        })}
      </ul>
    </div>
  );
};

登録済みの todo を編集可能にする

todo を入力フォーム化する

すでに登録済みの todo を編集可能にするにするため、<li></li> タグ内で展開される各項目の todo.value<input /> タグでラップします。

src/App.tsx
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="text"
                value={todo.value}
                onChange={(e) => e.preventDefault()}
              />
            </li>
          );
        })}
      </ul>

ここでも onChange イベントではとりあえず e.preventDefault() しているので入力しても何の変化も起きません。

2021-04-25 10.04.48.png

登録済み todo が編集された時のコールバック関数を作成する

編集されたタスクの内容を適用し、そのタスクを古いものから新しいものへ入れ替えた状態に todos ステート を更新しなければいけません。

ステートを更新するコールバック関数には以下の要件が求められます。

  • どの todo が編集されたのか特定するため、その todoid を引数として受け取る
  • e.target.value(onChange イベントの結果)を書き換え後の todo.value の値とするために第2引数として受け取る
  • 編集後の todo を含む Todo 型の配列todos ステート を書き換える
src/App.tsx
  const handleOnEdit = (id: number, value: string) => {
    /**
     * 引数として渡された todo の id が一致する
     * todos ステート(のコピー)内の todo の
     * value プロパティを引数 value (= e.target.value) に書き換える
     */
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    // todos ステートを更新
    setTodos(newTodos);
  };

Array.prototype.map() は、配列のコピーに変更を加えた結果からなる新しい配列を生成する非破壊的メソッドです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

上のコールバック関数を <input onChange={} /> イベントに紐付けます。

src/App.tsx
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="text"
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
               />
            </li>
          );
        })}
      </ul>

ステートのイミュータビリティは保たれているか?

では、上の処理を行うことによってもステートのイミュータビリティimmutability, 不変性)は保たれているのでしょうか?

結論から言うと、この手法ではイミュータビリティを保つことはできません。

上のコードの newTodos 配列を作成した(かつ todos ステートを更新する)の todos ステートの値を確認してみましょう。

  const handleOnEdit = (id: number, value: string) => {
    const newTodos = todos.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    // todos ステートが書き換えられていないかチェック
    console.log('=== Original todos ===');
    todos.map((todo) => console.log(`id: ${todo.id}, value: ${todo.value}`));

    setTodos(newTodos);
  };

結果は以下のようになります。

setTodos(newTodos) が実行される前に todos ステート配列が直接ミューテートされてしまっています。

Array.prototype.map() は、「新しい配列を生成する非破壊的メソッド」であるはずなのに何故なのでしょうか?

配列ステートの操作には要注意 (その 2)

Array.map() メソッドやスプレッド構文はシャロー(薄い)コピー

chap.07 ではスプレッド構文によって保たれたイミュータビリティが、前章の Array.prototype.map() メソッドでは保てませんでした。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

なぜなら、配列の要素であるオブジェクトの中で入れ子になっているプロパティ(= todos ステート配列を構成するそれぞれの Todo 型オブジェクトvalue プロパティ)のイミュータビリティを維持するには、シャローコピー(薄いコピー) では不十分だからです。

シャローコピーでは、オブジェクト(や配列)内で入れ子になった要素は原本(コピー元配列)のそれを変わらず参照しています。これを変更すると原本の要素を変更してしまいます。

chap.07 では、もう一段上のレイヤー、つまり配列の要素そのものの追加であったためにシャローコピーによる操作で十分だったのです。

本稿では、JavaScript (=TypeScript) におけるシャローコピー・ディープコピーについてはこれ以上詳しく触れません。以下のような素晴らしい解説記事がたくさん存在していますので、これらを参照してください。

https://zenn.dev/luvmini511/articles/722cb85067d4e9

そして、『スプレッド構文による配列のコピー』や 『Array.map() メソッドによる新配列の生成』は、このシャローコピーにあたります。

原本(コピー元配列)の要素をミューテートから守るためには、完全にコピー(=ディープコピー)された別の配列を用意し、その配列の要素を変更しなければいけません。

ディープコピーでイミュータビリティを確保する

では、前章のコードをディープコピーで書き換えましょう。
いったん todos ステート配列をディープコピーしたものに Array.map() を適用します。

src/App.tsx
  const handleOnEdit = (id: number, value: string) => {
    /*
    * ディープコピー: いったん JSON に変換した後で復元する
    */
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    // ディープコピーされた配列に Array.map() を適用
    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    // todos ステート配列をチェック(あとでコメントアウト)
    console.log('=== Original todos ===');
    todos.map((todo) => console.log(`id: ${todo.id}, value: ${todo.value}`));

    setTodos(newTodos);
  };

この章のソースコード全文
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
};

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  const handleOnEdit = (id: number, value: string) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input type="text" value={text} onChange={(e) => handleOnChange(e)} />
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
      </form>
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="text"
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

タスクの完了/未完了を操作できるようにする - Todo の仕様を考える (その 3)

Todo 型オブジェクトの再拡張

タスクの完了/未完了を示すフラグを Todo 型に追加しましょう。
完了/未完了 (= true or false) を表すので型は Boolean 型 となります。

src/App.tsx
type Todo = {
  value: string;
  readonly id: number;
  // 完了/未完了を示すプロパティ
  checked: boolean;
};

TODO 型オブジェクトには checked プロパティが必須となったため、 handleOnSubmit() メソッドを更新します。

src/App.tsx
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      // 初期値(todo 作成時)は false
      checked: false,
    };

    setTodos([newTodo, ...todos]);

それぞれの todo の前へ、完了/未完了を操作をするためのチェックボックスを置きます。

App.tsx
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                checked={todo.checked}
                onChange={(e) => e.preventDefault()}
              />
              <input
                type="text"
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
            </li>
          );
        })}
      </ul>

2021-04-25 10.07.23.png

チェックボックスがチェックされたときのコールバック関数を作成する

前々章の handleOnEdit() コールバック関数とパターンは同じです。

どの todo がチェックされたのか特定するための idonChange イベントの結果を引数として受け取り、その todo 型オブジェクトchecked プロパティを反転させます。

src/App.tsx
  const handleOnCheck = (id: number, checked: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.checked = !checked;
      }
      return todo;
    });

    setTodos(newTodos);
  };

チェックボックスのイベントへ紐付けましょう。

src/App.tsx
 return (
   <li key={todo.id}>
    <input
      type="checkbox"
      checked={todo.checked}
      onChange={() => handleOnCheck(todo.id, todo.checked)}
    />
    <input
      type="text"
      value={todo.value}
      onChange={(e) => handleOnEdit(todo.id, e.target.value)}
    />
   </li>
 );

2021-04-25 10.18.15.png

このままではチェック済みのタスクも編集できてしまうので、チェック済みの項目は入力フォームを無効にします。

src/App.tsx
  <input
    type="text"
    disabled={todo.checked}
    value={todo.value}
    onChange={(e) => handleOnEdit(todo.id, e.target.value)}
  />

2021-04-25 10.22.45.png

この章のソースコード全文 App.tsx
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
};

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  const handleOnEdit = (id: number, value: string) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnCheck = (id: number, checked: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.checked = !checked;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input type="text" value={text} onChange={(e) => handleOnChange(e)} />
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
      </form>
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                checked={todo.checked}
                onChange={() => handleOnCheck(todo.id, todo.checked)}
              />
              <input
                type="text"
                disabled={todo.checked}
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

登録済みの todo を削除可能にする - Todo の仕様を考える (その 4)

Todo 型オブジェクトの再拡張ふたたび

タスクの削除/未削除を示すフラグを Todo 型に追加しましょう。
これも true or false を表すプロパティなので型は Boolean 型 となります。

src/App.tsx
type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};

前章の checked のときと同じく、 handleOnSubmit() メソッドを更新する必要があります。

src/App.tsx
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
      removed: false,
    };

    setTodos([newTodo, ...todos]);

削除ボタンの追加

それぞれの入力フォームの後ろへ削除ボタンを追加します。

App.tsx
  return (
    <li key={todo.id}>
      <input
        type="checkbox"
        checked={todo.checked}
        onChange={() => handleOnCheck(todo.id, todo.checked)}
      />
      <input
        type="text"
        disabled={todo.checked}
        value={todo.value}
        onChange={(e) => handleOnEdit(todo.id, e.target.value)}
      />
      <button onClick={() => console.log('removed!')}>削除</button>
    </li>
  );

2021-04-25 10.32.55.png

削除ボタンがクリックされたときのコールバック関数を作成する

これも前章の handleOnChecked() とまったく同じパターンです。
同様に onClick イベントへ紐付けします。

src/App.tsx
  const handleOnRemove = (id: number, removed: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.removed = !removed;
      }
      return todo;
    });

    setTodos(newTodos);
  };

すでに削除済みかどうかを可視化するため、todo.removed の値によってボタンのラベルを入れ替えましょう。

src/App.tsx
    <button onClick={() => handleOnRemove(todo.id, todo.removed)}>
      {todo.removed ? '復元' : '削除'}
    </button>

削除されたアイテムは改変できないようにするため、チェックボックスと入力フォームも無効化します。

src/App.tsx
  <input
    type="checkbox"
    disabled={todo.removed}
    checked={todo.checked}
    onChange={() => handleOnCheck(todo.id, todo.checked)}
  />
  <input
    type="text"
    disabled={todo.checked || todo.removed}
    value={todo.value}
    onChange={(e) => handleOnEdit(todo.id, e.target.value)}
  />

2021-04-25 10.51.02.png

この章のソースコード全文 App.tsx
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
      removed: false,
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  const handleOnEdit = (id: number, value: string) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnCheck = (id: number, checked: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.checked = !checked;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnRemove = (id: number, removed: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.removed = !removed;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input type="text" value={text} onChange={(e) => handleOnChange(e)} />
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
      </form>
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                disabled={todo.removed}
                checked={todo.checked}
                onChange={() => handleOnCheck(todo.id, todo.checked)}
              />
              <input
                type="text"
                disabled={todo.checked || todo.removed}
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
              <button onClick={() => handleOnRemove(todo.id, todo.removed)}>
                {todo.removed ? '復元' : '削除'}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

タスクをフィルタリングする機能を追加する

このままでは、完了済みアイテムや削除済みアイテムもいっしょにそのまま表示されてしまうので、タスクをフィルタリングする機能を追加します。

フィルタリングするセレクタを作成

ここでも onChange イベントへはとりあえずダミーを与えておきます。

src/App.tsx
    <div>
      <select defaultValue="all" onChange={(e) => e.preventDefault()}>
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">現在のタスク</option>
        <option value="removed">ごみ箱</option>
      </select>
      <form onSubmit={(e) => handleOnSubmit(e)}>
~ snip ~
    </div>

現在のフィルターを格納する filter ステートを追加する

フィルターの状態をあらわす Filter 型を新設し、 その種別は4種類とします。

src/App.tsx
type Filter = 'all' | 'checked' | 'unchecked' | 'removed';
フィルター タスクの種別
all すべてのタスク(削除済みのタスクをのぞく)
checked 完了したタスク
unchecked 現在の(未完了の)タスク
removed ごみ箱(削除済みのタスク)

前項の <option /> タグの値を Filter 型のステート として保持しましょう。

src/App.tsx
export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  // 追加
  const [filter, setFilter] = useState<Filter>('all');

上のセレクタの値が変化 (onChange イベントの発火)すると filter ステートを更新させるようにします。

Filter を単なる string 型 にすれば下のようなキャスト(=型変換)は不要ですが、次項の switch 文で型によるエディタの補完を享受するため、あえて Filter 型 を適用しています。

src/App.tsx
      // e.target.value: string を Filter 型にキャストする
      <select
        defaultValue="all"
        onChange={(e) => setFilter(e.target.value as Filter)}
      >
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">現在のタスク</option>
        <option value="removed">ごみ箱</option>
      </select>

https://developer.mozilla.org/ja/docs/Glossary/Type_Conversion

フィルタリング後の Todo 型の配列をリスト表示する

todos ステート 配列の表示方法を変化させる関数を作成しましょう。

  • <ul></ul> タグの中で展開されている todos ステート をタグへ渡す前に加工する
  • 現在の filter ステート に応じて Todo 型配列 の要素をフィルタリングする
  • Array.prototype.filter() メソッドも非破壊メソッドかつシャローコピー
  • Todo 型オブジェクト内のプロパティを編集するわけではないので、イミュータビリティには影響がない

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
src/App.tsx
  const filteredTodos = todos.filter((todo) => {
    // filter ステートの値に応じて異なる内容の配列を返す
    switch (filter) {
      case 'all':
        // 削除されていないもの全て
        return !todo.removed;
      case 'checked':
        // 完了済 **かつ** 削除されていないもの
        return todo.checked && !todo.removed;
      case 'unchecked':
        // 未完了 **かつ** 削除されていないもの
        return !todo.checked && !todo.removed;
      case 'removed':
        // 削除済みのもの
        return todo.removed;
      default:
        return todo;
    }
  });

todos ステートを展開する <ul></ul> タグにフィルタリング済みのリストを渡すように書き換えます。

src/App.tsx
        <ul>
 -         {todos.map((todo) => {
+         {filteredTodos.map((todo) => {
            return (
              <li key={todo.id}>
                <input
                  type="checkbox"
                  disabled={todo.removed}
 

「ごみ箱」「完了済みのタスク」が表示されている時は、あらたなタスクを追加できないように入力フォームは無効化しましょう。

src/App.tsx
      <form onSubmit={(e) => handleOnSubmit(e)}>
        <input
          type="text"
          value={text}
          disabled={filter === 'checked' || filter === 'removed'}
          onChange={(e) => handleOnChange(e)}
        />
        <input
          type="submit"
          value="追加"
          disabled={filter === 'checked' || filter === 'removed'}
          onSubmit={(e) => handleOnSubmit(e)}
        />
      </form>

2021-04-25-122246.png

この章のソースコード全文 App.tsx
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};

type Filter = 'all' | 'checked' | 'unchecked' | 'removed';

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<Filter>('all');

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
      removed: false,
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  const handleOnEdit = (id: number, value: string) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnCheck = (id: number, checked: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.checked = !checked;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnRemove = (id: number, removed: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.removed = !removed;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const filteredTodos = todos.filter((todo) => {
    switch (filter) {
      case 'all':
        return !todo.removed;
      case 'checked':
        return todo.checked && !todo.removed;
      case 'unchecked':
        return !todo.checked && !todo.removed;
      case 'removed':
        return todo.removed;
      default:
        return todo;
    }
  });

  return (
    <div>
      <select
        defaultValue="all"
        onChange={(e) => setFilter(e.target.value as Filter)}
      >
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">現在のタスク</option>
        <option value="removed">ごみ箱</option>
      </select>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input
          type="text"
          value={text}
          disabled={filter === 'checked' || filter === 'removed'}
          onChange={(e) => handleOnChange(e)}
        />
        <input
          type="submit"
          value="追加"
          disabled={filter === 'checked' || filter === 'removed'}
          onSubmit={handleOnSubmit}
        />
      </form>
      <ul>
        {filteredTodos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                disabled={todo.removed}
                checked={todo.checked}
                onChange={() => handleOnCheck(todo.id, todo.checked)}
              />
              <input
                type="text"
                disabled={todo.checked || todo.removed}
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
              <button onClick={() => handleOnRemove(todo.id, todo.removed)}>
                {todo.removed ? '復元' : '削除'}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

ごみ箱を空にする機能を追加する

「ゴミ箱を空にする」ボタンの作成

フィルターで「ごみ箱」の Todo リストを表示しているときには、削除済みタスクを完全に消去できるように機能追加しましょう。

フィルターが「ごみ箱」の場合は「ゴミ箱を空にする」ボタンを表示し、それ以外のときは従前の入力フォームを表示するよう改修します。

src/App.tsx
        <option value="removed">ごみ箱</option>
      </select>
      {filter === 'removed' ? (
        <button onClick={() => console.log('remove all')}>
          ゴミ箱を空にする
        </button>
      ) : (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            handleOnSubmit();
          }}
        >
          <input
            type="text"
            value={text}
            disabled={filter === 'checked' || filter === 'removed'}
            onChange={(e) => handleOnChange(e)}
          />
          <input
            type="submit"
            value="追加"
            disabled={filter === 'checked' || filter === 'removed'}
            onSubmit={handleOnSubmit}
          />
        </form>
      )}
      <ul>
        {filteredTodos.map((todo) => {

こうなると入力フォームが描画される場合には filter === 'removed' という状態が発生し得ないので、入力フォームからこれらを削除しなければいけません。

src/App.tsx
        <form
          onSubmit={(e) => {
            e.preventDefault();
            handleOnSubmit();
          }}
        >
          <input
            type="text"
            value={text}
            disabled={filter === 'checked'}
            onChange={(e) => handleOnChange(e)}
          />
          <input
            type="submit"
            value="追加"
            disabled={filter === 'checked'}
            onSubmit={handleOnSubmit}
          />
        </form>

fig01.png

「ゴミ箱を空にする」コールバック関数の作成と紐付け

todos ステート配列から removed フラグが立っている要素を取り除くのみなので、これまでと同様のパターンで処理すれば良いでしょう。

src/App.tsx
  const handleOnEmpty = () => {
    // シャローコピーで事足りる
    const newTodos = todos.filter((todo) => !todo.removed);
    setTodos(newTodos);
  };
src/App.tsx
      {filter === 'removed' ? (
        // コールバックに handleOnEmpty() を渡す
        <button onClick={handleOnEmpty}>ゴミ箱を空にする</button>
      ) : (

また、ゴミ箱が空の場合(= removed フラグが立っているタスクが todos ステート配列に存在しない)にはボタンを無効化します。

src/App.tsx
        <button
          onClick={handleOnEmpty}
          disabled={todos.filter((todo) => todo.removed).length === 0}
        >
          ゴミ箱を空にする
        </button>

fig02.png

ここまでで React による Todo アプリはいったん完成です。

この章のソースコード全文 App.tsx
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
  checked: boolean;
  removed: boolean;
};

type Filter = 'all' | 'checked' | 'unchecked' | 'removed';

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<Filter>('all');

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
      checked: false,
      removed: false,
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  const handleOnEdit = (id: number, value: string) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnCheck = (id: number, checked: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.checked = !checked;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnRemove = (id: number, removed: boolean) => {
    const deepCopy: Todo[] = JSON.parse(JSON.stringify(todos));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.removed = !removed;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  const handleOnEmpty = () => {
    const newTodos = todos.filter((todo) => !todo.removed);
    setTodos(newTodos);
  };

  const filteredTodos = todos.filter((todo) => {
    switch (filter) {
      case 'all':
        return !todo.removed;
      case 'checked':
        return todo.checked && !todo.removed;
      case 'unchecked':
        return !todo.checked && !todo.removed;
      case 'removed':
        return todo.removed;
      default:
        return todo;
    }
  });

  return (
    <div>
      <select
        defaultValue="all"
        onChange={(e) => setFilter(e.target.value as Filter)}
      >
        <option value="all">すべてのタスク</option>
        <option value="checked">完了したタスク</option>
        <option value="unchecked">現在のタスク</option>
        <option value="removed">ごみ箱</option>
      </select>
      {filter === 'removed' ? (
        <button
          onClick={handleOnEmpty}
          disabled={todos.filter((todo) => todo.removed).length === 0}
        >
          ゴミ箱を空にする
        </button>
      ) : (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            handleOnSubmit();
          }}
        >
          <input
            type="text"
            value={text}
            disabled={filter === 'checked'}
            onChange={(e) => handleOnChange(e)}
          />
          <input
            type="submit"
            value="追加"
            disabled={filter === 'checked'}
            onSubmit={handleOnSubmit}
          />
        </form>
      )}
      <ul>
        {filteredTodos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="checkbox"
                disabled={todo.removed}
                checked={todo.checked}
                onChange={() => handleOnCheck(todo.id, todo.checked)}
              />
              <input
                type="text"
                disabled={todo.checked || todo.removed}
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
              <button onClick={() => handleOnRemove(todo.id, todo.removed)}>
                {todo.removed ? '復元' : '削除'}
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

https://zenn.dev/sprout2000/books/76a279bb90c3f3

https://zenn.dev/sprout2000/articles/1b52258b507b70

この記事に贈られたバッジ

Discussion

ログインするとコメントできます