このハンズオンでやることReactでシングルページアプリケーションを書こう
想定時間2h
前提知識・用語ブラウザのDOM操作

# Reactでシングルページアプリケーションを書こう

# 講義の全体像

この講義では 「ToDoアプリを作ってReactによる実践的な実装パターンを体感しよう」 を目標として、以下の構成で進めていきます。

  • Reactの基本的な動作を体感する(プロパティ、State、イベントハンドラ)
    • ⛳️プログラミング初心者の人のゴール
  • ToDoアプリでより実践的な実装パターンを体感する
    • ⛳️通常のゴール
  • ToDoアプリでサーバとやり取りする
    • ⛳️進みが早い人のゴール

人によっては既に知っている内容も含まれると思いますので、適宜先へ先へと進めていってください。

通常のゴールまで辿り着けば、以下のようなアプリが完成する予定です。

頑張っていきましょう😉

# 実行環境の用意

この講義では実行環境として以下の2つを想定します。 環境構築に慣れていない人には1のCodeSandboxを、実際の開発環境に近いことをしたい人には2のViteをお勧めします。

# 1. CodeSandboxを利用する

CodeSandbox (opens new window)を利用してReactの環境を用意します。

React TypeScriptの環境であるhttps://codesandbox.io/s/react-typescript-react-ts (opens new window)を開いてください。 以下のようなページが表示されると思います。

以上で環境のセットアップはほぼ完了です。簡単ですね😉

この環境ではエディタとブラウザが左右に表示されており、ファイルをセーブすると自動でブラウザの内容が更新されます。

残るちょっとした作業として、src/index.tsxの以下の場所にimport "./styles.css"の行を追加しておいてください。




 

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
1
2
3
4

これからApp.tsxは頻繁に書き換えるため、代わりに特に触ることのないindex.tsxからスタイルシートをimportするようにします。

# 2. Viteを利用してローカル環境にプロジェクトの雛形を用意する

Vite (opens new window)を利用してローカル環境にプロジェクトの雛形を用意します。

Viteを利用した環境のセットアップを例示しておきます。 難しそうであればCodeSandboxを利用してください。

# NodeJSのインストール(インストール済みの場合はスキップ)
# nodeコマンドとnpmコマンドが使えるようになれば方法は任意です
# 以下はasdfを利用した一例です
asdf plugin add nodejs
asdf install nodejs latest
asdf global nodejs latest

# Viteを利用したプロジェクトのセットアップ
npm create vite@latest bootcamp-react
# => React => TypeScript を選択する
cd bootcamp-react
npm install
npm run dev
1
2
3
4
5
6
7
8
9
10
11
12
13

開発サーバが起動し、ブラウザでhttp://localhost:5173 (opens new window)にアクセスして以下のような画面が見れれば成功です😉

この環境ではファイルを更新すると自動でブラウザの表示も更新されます。 開発のためのエディタは好きなものを利用してください。

# フォルダ構成

Reactのプロジェクトのフォルダにはいろんなファイルがありますが、この講義ではsrcフォルダだけを気にしてもらえれば大丈夫です。srcフォルダの中にもいろんなファイルがありますが、ひとまず以下に示す3つのファイルのみを気にしてください。

(プロジェクトのフォルダ)
├── src
│   ├── App.tsx     ← メインのアプリ実装
│   ├── index.tsx   ← アプリのセットアップ (Viteの場合はmain.tsx)
│   ├── styles.css  ← スタイルシート      (Viteの場合はindex.css)
│   └── (その他諸々)
└── (その他諸々)
1
2
3
4
5
6
7

この講義では主にsrc/App.tsxを編集してReactの動作を確認していきます。

以降はCodeSandboxのファイル構成で説明します

Vite環境の方は適宜読み替えをお願いします。

# スタイルシートの適用

今回のハンズオン用のスタイルシートをあらかじめgithub.com/asa-taka/bootcamp-todo (opens new window)に用意しておきました。 それぞれの環境ごとに以下のファイルにコピペで上書きしてください。

  • CodeSandbox環境: src/styles.css
  • Vite環境: src/index.css

# 🚩チェックポイント

ここまでで以下の準備が終わっていれば完璧です😉

  • CodeSandbox、もしくはViteを利用してReactの開発環境の準備ができた
    • コードを編集する準備と、ブラウザの表示を確認する準備ができた
  • Reactのプロジェクトのsrcフォルダが確認できた
  • ハンズオン用のスタイルシートの適用が終わった

# 要素技術の軽い紹介

手を動かす前に軽くReactと、Reactと共に使われる要素技術の紹介をしておきます。 まだセットアップが間に合っていない人はTAを呼ぶなりして、この時間に頑張って間に合わせてください😉

# Reactが解決してくれる課題

先に行われたDOMの講義を通して、ブラウザの標準APIとして存在するDOM操作のメソッドを利用し、表示中の画面を書き換えられることは体感できたと思います。

しかしDOM操作を直接行う方法では、次のような複雑なDOMに対して変更を行おうとした場合には大変になります。

<div class="profile-list">
  <div class="profile-container">
    <div class="profile-pict">
      <img src="...">
    </div>
    <div class="profile">
      <p>...</p>
    </div>
  </div>
  <div class="profile-container">
    <!-- ...繰り返し... -->
  </div>
<div>
1
2
3
4
5
6
7
8
9
10
11
12
13

昨今のHTMLはネストが深く属性も山盛りです。 そして実際のウェブアプリはこの数十倍の複雑さになります。 さらにアプリの種類によっては頻繁で細やかな表示の変更が求められることもあります。

この膨大なDOMに対して、変更内容に応じた更新をかける処理をブラウザの素朴なAPIを利用して開発するのは現実的ではありません。

Reactを利用することでこのような処理が簡単に描けるようになります。

# React

React (opens new window)はMeta(旧Facebook)発のUIフレームワークです。

ReactとはデータとDOMの対応付けをやってくれるフレームワークであり、さらにデータの更新に対してリアクティブ(反応的)に画面を更新してくれるフレームワークです。 この「リアクティブな画面更新」を行う機能により複雑で画面が繊細に更新されるようなウェブアプリを効率的に開発することができます。 その辺りはSvelteと同様の位置付けのフレームワークです。

…と、こんなことを言われてもピンとこないですよね。それを理解するためのハンズオンです😉

# TypeScript

Reactの開発ではJavaScriptの代わりに、その拡張言語であるTypeScript (opens new window)をよく使います。 TypeScriptはMicrosoft発の、JavaScriptにの概念を加えた言語です。

例えば以下の関数定義は

const fn = (n) => n * 2
1

TypeScriptでは次のように書けます。

const fn = (n: number) => n * 2
1

引数にnumberという型をつけることができます。 これにより「この関数の引数はどのような値であるべきか」という、JavaScriptではコメントで表現するしかなかった部分を補完することができます。 逆に型以外の点ではJavaScriptとほぼ同じ表記になります。

もう少し複雑なオブジェクトを定義して用いることもできます。

type Props = {
  value: number
}

const fn = (props: Props) => props.value * 2

fn({ value: 12 }) // => エラーなし
1
2
3
4
5
6
7

ここではPropsという型を定義して関数の引数に利用しています。 Reactでは主にプロパティやStateの型、それからそのアプリが扱うデータの型を定義するのに便利になります。

TypeScriptを試してみる

TypeScriptでどのような表現が可能かはTS Playground (opens new window)で実際に試すと理解しやすいでしょう。

またDeno (opens new window)というNodeJSとは別のJavaScript実行環境ではデフォルトでTypeScriptを実行できます。

TypeScriptの実行環境

TypeScriptは通常、そのままではブラウザやNodeJSなどのJavaScriptの実行環境では実行できず、以下の流れで処理する必要があります。

  1. TypeScriptでソースコードを記述
  2. コンパイラ(トランスパイラ)が型チェックをしながらソースコードをJavaScriptに変換
  3. ブラウザ等JavaScriptの動作環境で実行

実際にはこの流れはツール類がほぼ自動で実行してくれます。 包括的なツールとして最近ではVite (opens new window)がよく使われます。

面倒に見えるかもしれませんが、最近ではツール類が洗練され、設定の手間がほとんどかからなくなっています。 一般にReactで開発を行う際もTypeScriptがよく使われるため、この講義でもTypeScriptの利用を前提として進めます。

# JSX(TSX)

さらにReactではJSXというJavaScriptの拡張言語を使います。 JSXは簡単に言えば「JavaScriptのコード中にHTMLを書けるようにした言語」です。

例えば以下のような表現ができます。

function Hello() {
  const myName = 'asa-taka'
  return (
    <div>Hello <b>{myName}</b></div>
  )
}
1
2
3
4
5
6

どうでしょうか。気持ち悪いですね😉

JavaScriptとHTMLが入り混じっています。 ここでは2つだけJSXのルールを紹介しておきますが、まだ覚える必要はありません。

JSXのルール

  • JSX要素はreturnや変数への代入など、JavaScriptの「値(より正確には式)」として、任意の場所で使える
    • ただしルートの要素は1つでなければならない
  • JSXの中(要素の属性値or子要素)では{}で囲むことでJavaScriptの表現が使える
    • ただし値を返すもの(簡単に言えばそのまま変数に代入できるもの)に限ります
    • 例として{1}{fn(1, 'foo') + 1}は正しいJSX中の表現ですが{if ...}は使えません
      • 条件分岐には&&?:が、繰り返しには配列のmapメソッドなどがよく使われます

ハンズオンを進めながら、理解を深めたくなったらまた戻ってきてください。

ウェブアプリを開発する上では慣れるとJSXの方が便利なのと、他のReactの解説でもほぼJSXが使われているため、この講義でもJSXの利用を前提として進めます。

TypeScriptとJSXを合わせたものは特にTSXと呼ばれることもありますが、その場合でもJSXと呼ばれることが多いです。 ただし拡張子は.jsxではなく.tsxがよく使われます。

HTMLとは属性名が異なる場合がある

classclassNameになっているなど、JavaScriptの予約語の都合上、属性名が通常のHTMLとは異なるものがあるので気をつけてください。 他にもforhtmlForになっていたりします。

ただしこれらはJSX独自の仕様というよりはWeb API (opens new window)の仕様から来るものに思われます。

JSXにも変換処理が必要

TypeScriptと同様、JSXも純粋なJavaScriptではないため、ブラウザ上で動作させるためにはJavaScriptに変換する処理が必要になります。 ただしこの変換も、今回セットアップしたようなツールにより自動化されています。

JSXは実際には何を表しているのか

JSXが最終的なJavaScriptでどのような値になるかはコンパイラの設定によります。 Reactの場合はReactElementという特定の型のオブジェクトを返し、詳細は省きますがこれはReactによるVirtual DOMのノードの表現です。

コンパイラの変換処理としては

const element = <div className="app" />
1

というJSXのコードは

const element = React.createElement('div', { className: 'app' })
1

という純粋なJavaScriptのコードと等価です。 ReactElementが具体的にどのようなオブジェクトなのか、気になる人は適当にconsole.log(<div />)を含めたコードの出力をブラウザのコンソールで覗いてみてください。

このようにシンプルな変換処理(シンタックスシュガー)があるだけで実体としては純粋なJavaScriptと同じと思ってもらえると、初見での気持ち悪さはだいぶ軽減されると思います。

# 🚩チェックポイント

この章では以下のことを説明しましたが、まだそこまで理解できていなくても大丈夫です😉

  • Reactはデータの変更に対しリアクティブに画面を更新することで細やかな画面更新が必要なアプリを開発できるフレームワークである
  • Reactの開発ではTypeScriptという、JavaScriptに型を追加した拡張言語を使うと便利である
  • Reactの開発ではJSXという、JavaScriptの中にHTMLを書けるような拡張言語を使うのが主流である

# Reactコンポーネントとプロパティ

前置きが長くなりましたがReactを触っていきましょう😉

Reactでは画面を構成する部品を「コンポーネント」という単位で定義します。 ここでは最もシンプルなReactコンポーネントとして、プロパティを受け取り描画内容を返すだけのReactコンポーネントを作っていきましょう。

# とりあえずReactを動作させる

この講義では主にsrc/App.tsxを編集してReactの動作を確認していきます。 手始めにApp.tsx丸ごと以下のように書き換えてください。既にあるコードはすべて消してしまって大丈夫です。

export default function App() {
  return (
    <div className="App">
      <p>
        Hello, <b>React!</b>
      </p>
    </div>
  );
}
1
2
3
4
5
6
7
8
9

成功すると以下のような表示になります😉

おめでとうございます。ここではAppというコンポーネントを定義しました。 Reactではコンポーネントを「DOMのようなもの」を返す関数として定義します。 この場合Appdiv要素に囲まれた「Hello, React!」と表示される「DOMのようなもの」を表しています。 「DOMのようなもの」だと言いにくいので以降は「JSX要素」と呼ぶことにします。

そしてAppというコンポーネントはsrc/index.tsxで読み込まれDOMとしてレンダリング(DOMを操作して描画)されます。

# コンポーネントを切り分けてみる

先ほど「コンポーネント」とは「画面を構成する部品」と説明しました。 この考え方を理解するために、Appコンポーネントの描画内容の一部を切り分けてみましょう。

以下のようにApp.tsxを書き換えてください。

 
 
 
 
 
 





 




function Hello() {
  return (
    <p>
      Hello, <b>React!</b>
    </p>
  );
}

export default function App() {
  return (
    <div className="App">
      <Hello />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

この変更では表示内容は特に変わりません。

新しくHelloというコンポーネントを定義し、Appの中にあった「Hello, React!」のp要素を切り分けました。 そして新しい表現として、そのHelloコンポーネントをAppの中で<Hello />として利用しています。 このように他のコンポーネントもJSXの表記により利用することができます。

このAppの中でHelloが使われる、という関係を指してそれぞれを 親コンポーネント (ここではApp)、子コンポーネント (ここではHello)と呼びます。

# プロパティで親コンポーネントから値を渡す

コンポーネントには引数としてオブジェクトを渡すことができます。 これをReactでは「プロパティ」と呼んでいます。 プロパティを利用することでコンポーネントの動作や表示内容にバリエーションを持たせることができます。

HelloコンポーネントにyourNameプロパティを追加して、挨拶する相手を指定できるようにしてみましょう。

以下のようにApp.tsxを書き換えてください。

 
 
 
 
 


 







 




type HelloProps = {
  yourName: string;
};

function Hello({ yourName }: HelloProps) {
  return (
    <p>
      Hello, <b>{yourName}!</b>
    </p>
  );
}

export default function App() {
  return (
    <div className="App">
      <Hello yourName="asa-taka" />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

成功すると以下のような表示になります😉

type HelloPropsというのはTypeScriptの表現であり、ここではHelloコンポーネントがプロパティとして何を受け付けるかを表すための型を定義しています。 そして受け取ったyourNameプロパティを{yourName}としてJSXの中で参照しています。 Reactではこのように汎用性を持たせながら、機能や描画内容の切り出しを行います。

さて、これができたら続けてHelloコンポーネントのyourNameプロパティを変更して複数回使用してみましょう。

以下のようにApp.tsxを書き換えてください。

















 
 




type HelloProps = {
  yourName: string;
};

function Hello({ yourName }: HelloProps) {
  return (
    <p>
      Hello, <b>{yourName}!</b>
    </p>
  );
}

export default function App() {
  return (
    <div className="App">
      <Hello yourName="asa-taka" />
      <Hello yourName="igarashi" />
      <Hello yourName="ueda" />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

成功すると以下のような表示になります😉

コンポーネントの切り分けと一部の要素をプロパティとして抜き出すことで整理されたコードが書けることがわかると思います。 コンポーネントらしくなってきましたね。

# 繰り返し処理

JSXの中では{}で囲むことでJavaScriptの表現がそのまま使えるので、これを利用して更に処理をプログラム的にしてみましょう。 繰り返し処理を利用して同じような要素の生成をまとめて行います。

繰り返し処理といえばfor文ですが、これはJSXの中では直接は使うことができないため、配列のmap (opens new window)メソッドがよく利用されます。

配列のmapメソッドは、例えば以下のように使われるメソッドです。

const list = [1, 2, 3, 4]
const doubled = list.map((x) => x * 2)
console.log(doubled)
// => [2, 4, 6, 8]
1
2
3
4

というように、配列の全ての要素に対して引数で渡された関数による変換を行った新たな配列を生成します。 この場合は(x) => x * 2という「引数を倍にして返す関数」を渡すことで、各要素を倍にした配列を得ることができます。

Reactではこれを、配列からJSXの要素を生成するためによく使います。 つまり、配列の個々の要素を受け取りJSXを返す関数を書くことでJSXの要素の配列を生成することができます。

アロー関数

アロー関数は(引数1, 引数2, ...) => { ... }の形で定義される関数です。 {}内の式(expression)が一つの場合は{}を省略でき、その場合はその式の値がそのまま返り値になります。

functionによる関数定義とは何点か違いがありますが、この講義では以下のように使い分けています。

  • コンポーネント定義ではfunctionを利用する
    • function による定義を行うとその関数に .name というプロパティで名前がつくため
    • この名前により開発用の拡張機能やコンポーネントのエラーメッセージが読みやすくなる
  • それ以外の用途では=>を利用する
    • シンプルで読みやすいため

この辺りはプロジェクトごとに方針があるので、それに従うのが良いでしょう。

さて、前置きが長くなりましたが以下のようにApp.tsxを書き換えてください。














 


 
 
 




type HelloProps = {
  yourName: string;
};

function Hello({ yourName }: HelloProps) {
  return (
    <p>
      Hello, <b>{yourName}!</b>
    </p>
  );
}

export default function App() {
  const members = ["asa-taka", "igarashi", "ueda"];
  return (
    <div className="App">
      {members.map((member) => (
        <Hello key={member} yourName={member} />
      ))}
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

この変更では表示内容は特に変わりません。

配列の要素にはkeyという特殊なプロパティが新たに必要になる点に注意してください。 ここでは配列の中で一意な値を設定する必要があるとだけ覚えておけば十分です。 今回はmemberの値ががそもそも全て異なっているので、それをそのままkeyに利用しました。

keyという特殊なプロパティ

keyはReactのJSXの中で配列を扱うときに必要となるプロパティで、要素のトラッキングのために利用されます。 配列の要素が更新された場合にDOMに正しくその更新を反映させるために使用されます。 コンポーネント側にはプロパティとして特に定義されている必要はありません。

参考: ja.react.dev - リストのレンダー (opens new window)

# 条件分岐で描画内容を変更する

繰り返し処理の次は条件分岐を実装してみましょう。

以下のようにApp.tsxを書き換えてください。






 
 
 
 
 
 
 



















type HelloProps = {
  yourName: string;
};

function Hello({ yourName }: HelloProps) {
  if (yourName.length > 5) {
    return (
      <p>
        こんにちは、<b>{yourName}!</b>
      </p>
    );
  }

  return (
    <p>
      Hello, <b>{yourName}!</b>
    </p>
  );
}

export default function App() {
  const members = ["asa-taka", "igarashi", "ueda"];
  return (
    <div className="App">
      {members.map((member) => (
        <Hello key={member} yourName={member} />
      ))}
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

成功すると以下のように、名前が5文字よりも長いメンバーに対しては「Hello」の代わりに「こんにちは」と表示されるようになります😉

さて、この書き方でも良いのですが、JSXの中で三項演算子?:を利用するともう少しシンプルに書けます。

以下のようにApp.tsxを書き換えてください。








 
 















type HelloProps = {
  yourName: string;
};

function Hello({ yourName }: HelloProps) {
  return (
    <p>
      {yourName.length > 5 ? "こんにちは、" : "Hello, "}
      <b>{yourName}!</b>
    </p>
  );
}

export default function App() {
  const members = ["asa-taka", "igarashi", "ueda"];
  return (
    <div className="App">
      {members.map((member) => (
        <Hello key={member} yourName={member} />
      ))}
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

すっきりしましたね😉

常にこの書き方が読みやすいとは限らないので、状況に応じて可読性の高い方を選んでください。

# 🚩チェックポイント

ここまでで、以下のことが理解できていると素晴らしいです😉

  • Reactではプロパティを受け取りJSX要素を返す関数コンポーネントとして定義し、画面を構成する
  • プロパティを利用することでコンポーネントの表示内容にバリエーションを持たせ、汎用性を高められる
  • 繰り返し処理や条件分岐を利用することで効率的に画面を構成することができる

# Stateとその変更に対するリアクティブな動作

ここまでの内容でReactのコンポーネントの基本的な表現を理解してもらえたと思います。 ただし、ここまでは単なるテンプレートエンジン的な処理しかしておらず、実はちょっとした便利関数を書けばブラウザのDOM APIでも同じようなことはできてしまいます。

ここからはReactの真骨頂である 「データの変更に応じて描画内容が変わる」 というリアクティブな描画更新処理を体感してもらいます。 そのためには State(状態) にまつわる処理と イベントハンドラ を理解する必要があります。

少し難易度が増しますが、頑張ってついてきてください😉

# カウンタプログラム

State(状態) という概念はReactに限らずいろんなフレームワークで取り扱われる概念です。 親から(プロパティなどで)渡される値ではなく、かつそのコンポーネントが動いている間に(ユーザとのやり取りの中などで)変化する値を コンポーネント自身 で保持したい場合に使われます。

…こんなことを言われてもピンとこないですよね。そのためのハンズオ😉(略)

というわけで以下の「カウンタプログラム」で実際に動作を確かめて欲しいと思います。

カウンタプログラムもReactに限らず、そのフレームワークでStateがどのように実現されているかをデモするためによく使われます。 動作としてはシンプルで、ボタンを押すとカウンタの値が1つずつ増えていくというものです。

App.tsx丸ごと書き換えて、以下のようにしてください。

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <p>count: {count}</p>
    </div>
  );
}

export default function App() {
  return (
    <div className="App">
      <Counter />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

ボタンを押すごとに値が「1→2→3→...」のように増えていけば成功です😉

新しく登場したuseStateイベントハンドラ について少し詳しく説明しましょう。

Reactコンポーネントでは、自身で値を保持する場合、値が更新された場合に描画内容が再評価される必要があるため、そのための仕組みとしてuseStateを利用します。 useStateは初期状態を引数(ここでは0)で渡し、状態の値(ここではcount)と状態を更新するための関数(ここではsetCount)を返します。

const [count, setCount] = useState(0)
1

この方法で定義したsetCountは、呼ばれるたびにコンポーネントの再評価のトリガーとなります。 これによりcountが更新された状態でコンポーネントが再評価され、コンポーネントの返す値の変化をReactが検知することで描画内容の更新が行われます。

分割代入

ちなみにこの変数の宣言方法は、配列の1番目の要素と2番目の要素をそれぞれ代入するという便利な書き方で、 分割代入(destructuring assignment) (opens new window)と呼ばれます。

状態を更新する手段を手にしたので、次にそれをユーザの操作により実行するためのReactにおける イベントハンドラ の説明をしましょう。 といってもDOMのイベントハンドラとほぼ同じように扱えます。

<button onClick={() => setCount(count + 1)}>+1</button>
1

onClickにはbuttonがクリックされた際に評価される任意の関数を書くことができ、ここではsetCount(count + 1)を渡すことで、ボタンを押すたびにcountの値を増やす処理をしています。

描画が更新される流れをまとめると以下のようになります。

  1. buttonをクリックする
  2. onClicksetCount(count + 1)が実行される
  3. setCountが実行されたことによりコンポーネントが再評価される
  4. コンポーネントが返す内容が変わったことをReactが検知する
  5. それに合わせてブラウザの描画内容が更新される

初めは難しいと思うので 「コンポーネントで値を保持したい場合はuseStateを使い、値の更新には第2引数の関数を使えば描画内容も更新される」 とだけ覚えておけば十分です。

このuseStateとイベントハンドラによる描画内容のリアクティブな更新パターンは、Reactによるアプリを作る上でいろんな形で現れるため、しっかり慣れ親しめるといいですね😉

State(状態)の具体例

状態と言われても何が状態になりうるのかピンとこない人も多いと思いますので、具体例をいくつか挙げておきます。

  • UIの状態: パネルの開閉状態、フィルタ条件、input要素の値、など
  • データの取得状態: サーバから取得したデータ自体、取得に失敗した場合のエラー、取得中か否か、など

これらの状態を保持するのにも一般的にuseStateが使われます。

useStateのようなものが必要となる理由をもう少し詳しく説明するなら

Reactのコンポーネントは一般的に、アプリが実行されている間に何度も関数として評価されます。 具体的には親コンポーネントが再評価された場合や、自身に渡されるプロパティが変更された場合に再評価されます。 その再評価を跨いでオブジェクトや文字列、数値などの値を保持したい場合にuseStateを利用します。 これだけなら関数のスコープ外の適当な変数に値を保持することも考えられますが、その変更を検知して再評価する必要があるためuseStateのようなものが必要となります。

ただし、フレームワークは使って覚えるものなので、今はこういった詳細を把握する必要はありません。

# 文字列のStateとonChange

カウンタプログラムでは実際に作るアプリのイメージが湧きづらいと思いますので、今度は入力された文字列をStateとして保持するパターンを見てみましょう。

App.tsx丸ごと、以下のように書き換えてください。

import { useState } from "react";

function TextInput() {
  const [text, setText] = useState("");
  return (
    <div>
      <input value={text} onChange={(event) => setText(event.target.value)} />
      <p>input: {text}</p>
    </div>
  );
}

export default function App() {
  return (
    <div className="App">
      <TextInput />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

入力欄に入力された値が下にそのまま表示されていれば成功です😉

カウンタプログラムではクリックが起きたことのみを検知すれば十分だったため() =>という書き方をしていましたが、 今回は(event) =>という書き方をしており、これによりイベントの送信元(この場合はinput要素)の情報を参照することができます。 event.targetはイベントの送信元であるinput要素を表し、さらにそのvalue属性で入力された値が取得できるため、event.target.valueで入力値を取得しています。

イベントハンドラにもいろんな種類があり、onChangeは入力欄の値が変わった場合に毎回実行され、この場合はキーによる入力が行われるごとに実行されます。 useStateは今回は文字列を保持するものとして定義しており、setText(event.target.value)を渡すことで、入力欄の値をStateとして保持しています。

Reactには双方向バインディングがない

ReactにはVueJSやSvelteのような双方向バインディングがないため「input要素の値」のような、素朴な感覚ではそのinput要素自身が持っていそうなStateを親コンポーネントが参照する場合には、(プロパティ経由で)そのStateの管理を親コンポーネントが担う必要があります。

今回のコードの場合はinputが「何を値として表示するか」と「その値を更新する手段」を親コンポーネントであるTextInputが管理し、それを子コンポーネントであるinput要素にvalueonChangeプロパティで与えているという図式です。

制御されたコンポーネントと非制御コンポーネント (opens new window) の言葉を使うなら、これは「制御されたコンポーネント」のパターンになります。

# フィルタ処理で見るもっとリアクティブな例

今の段階の知識で理解できる、ちょっと実践的な実装パターンとして「リストのフィルタ処理」を書いてみましょう😉

以下の2つのメソッドを利用することで、意外とシンプルに実装できてしまいます。

  • 配列のfilter (opens new window)メソッド
    • 各要素に対して関数を適用して、関数がtrueを返すもののみを含んだ新しい配列を返す
  • 文字列のincludes (opens new window)メソッド
    • 文字列が部分文字列を含む場合にtrueを返す

これらを利用してApp.tsx丸ごと以下のように書き換えてください。

import { useState } from "react";

function ListFilter() {
  const [text, setText] = useState("");
  const members = ["asa-taka", "igarashi", "ueda"];
  const filteredMembers = members.filter((member) => member.includes(text));
  return (
    <div>
      <input value={text} onChange={(event) => setText(event.target.value)} />
      {filteredMembers.map((member) => (
        <p key={member}>{member}</p>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <div className="App">
      <ListFilter />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

入力欄に適当な文字列を入力すると、それを含む要素だけが下に表示されるようになれば成功です😉

例えばasと入力するとその文字列を含むasa-takaigarashiが表示されます。 これはシンプルな検索機能として有用な実装パターンです。

members.filterに、各要素がtext(入力欄の値)を部分文字列として含む場合にtrueを返す関数を渡すことで実現しています。

これができたら更に以下のように書き換えてみてください。

フィルタの対象となるリストをmembersプロパティとして渡せるようにしています。 プロパティの選び方によりコンポーネントの汎用性を高められることがわかると思います。



 
 
 
 
 















 
 




import { useState } from "react";

type ListFilterProps = {
  members: string[];
};

function ListFilter({ members }: ListFilterProps) {
  const [text, setText] = useState("");
  const filteredMembers = members.filter((member) => member.includes(text));
  return (
    <div>
      <input value={text} onChange={(event) => setText(event.target.value)} />
      {filteredMembers.map((member) => (
        <p key={member}>{member}</p>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <div className="App">
      <ListFilter members={["asa-taka", "igarashi", "ueda"]} />
      <ListFilter members={["endo", "ogata", "kataoka"]} />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

表示は以下のようになれば成功です😉

こうすることでListFilterコンポーネントは「リストを表示して、それを検索するための入力欄も表示したい」という場合に汎用的に利用できるコンポーネントになりました。

ここまでの内容で、プロパティとState、イベントハンドラを利用して、なんとなくリアクティブに動作する例を紹介してきました。 だんだんと「自分でもアプリが作れるかもしれないな…🤔」と感じてもらえると嬉しいです。

# 🚩チェックポイント

ここまでで、以下のことが理解できていると素晴らしいです😉

  • useStateを利用するとコンポーネントに State(状態) を持たせることができる
  • Stateとイベントハンドラを組み合わせることで、ユーザの操作に対してリアクティブな動作を実装することができる

⛳️初心者の人向けのゴール

プログラミング初心者の方はここまでついてくるだけでも大変だったかもしれません。 よく頑張りましたね😉

# ToDoアプリ

さて、ここからはもう少し実践的な複雑さを持ったアプリとしてToDoアプリを作っていきましょう。 完成すると以下のような動作をするアプリになります。

これを実装するために、ここまでに学んできたコンポーネントの切り分け、Stateやイベントハンドラ、フィルタ処理を全部使っていきましょう😉

ToDoアプリを作る意義

新しくフレームワークを使おうとする場合、よく「ToDoアプリを作ると良い」と言われます(それをまとめたToDoMVC (opens new window)というサイトもある)。

理由としてはデータ操作に必要な「CRUD操作」を最小限の題材で網羅できるためだと思われます。

CRUD操作とはデータに対する基本的な操作である、以下の4種類の操作の頭文字をとったものです。

  • Create: 新規作成
  • Read: 閲覧
  • Update: 更新・変更
  • Delete: 削除

これらはAPIを設計する際やそれに対するフロントエンドを作成する際に基本となる考えになります。 例えばブログシステムを作ろうとする場合、記事に対するCRUD操作や、コメントに対するCRUD操作がそれぞれ必要になりそうだ、というように必要となる機能を洗い出すために参照できます。

そしてこれらの操作に対してはそれぞれある程度決まった実装パターンがあるため、このCRUDという操作の分類は 「この機能ははこんな感じに実装できる」 という感覚を自身の中で体系化するためにも役立ちます。 このToDoアプリを実装することでそれらの実装パターンを体感してもらえると嬉しいです😉

# フィルタ付きリストからToDoアプリの基礎を作る

まずはスタート地点として以下のようにApp.tsx丸ごと書き換えてください。 実装としてはフィルタ処理のものと比べてほぼ新しいことはしていないので、書き換えはコピペで済ませ「何をしていてどういう構成になっているか」の把握に時間を使って欲しいです。

import { useState } from "react";

/** リスト表示の対象となる、個々のToDoを表す型。*/
export type TodoItem = {
  /** 表示や操作の対象を識別するために利用する、全ての`TodoItem`の中で一意な値。 */
  id: number;
  /** ToDoの内容となる文字列。 */
  text: string;
  /** 完了すると`true`となる。 */
  done: boolean;
};

type TodoListItemProps = {
  item: TodoItem;
};

/** ToDoリストの個々のToDoとなるReactコンポーネント。 */
function TodoListItem({ item }: TodoListItemProps) {
  return (
    <div className="TodoItem">
      <p style={{ textDecoration: item.done ? "line-through" : "none" }}>
        {item.text}
      </p>
    </div>
  );
}

/** ToDoリストの初期値。 */
const INITIAL_TODO: TodoItem[] = [
  { id: 1, text: "todo-item-1", done: false },
  { id: 2, text: "todo-item-2", done: true },
];

/** アプリケーション本体となるReactコンポーネント。 */
export default function App() {
  const todoItems = INITIAL_TODO;
  const [keyword, setKeyword] = useState("");

  const filteredTodoItems = todoItems.filter((item) => {
    return item.text.includes(keyword);
  });

  return (
    <div className="App">
      <h1>ToDo</h1>
      <div className="App_todo-list-control">
        <input
          placeholder="キーワードフィルタ"
          value={keyword}
          onChange={(ev) => setKeyword(ev.target.value)}
        />
      </div>
      {filteredTodoItems.length === 0 ? (
        <div className="dimmed">該当するToDoはありません</div>
      ) : (
        <div className="App_todo-list">
          {filteredTodoItems.map((item) => (
            <TodoListItem key={item.id} item={item} />
          ))}
        </div>
      )}
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

表示としては以下のようになっていれば想定通りです😉

現時点での機能としては単純にフィルタ付きのリストです。

ちょっとした新しいこととしてstyleプロパティを利用してitem.donetrueの場合、つまりToDoが完了している場合にtext-decoration: line-througで取り消し線を表示するようにしています。

<p style={{ textDecoration: item.done ? "line-through" : "none" }}>{item.text}</p>
1

他にはクラス指定によりスタイルを適用していますが、Reactの処理の本質とは離れるためここでは割愛します。

# 状態確認用コンポーネントを追加する

ここからは「コンポーネントが現在どのような値を持っているか」を確認できると理解がしやすいため、そのための状態確認用コンポーネントValueViewerを追加します。

ここからはdiffコマンドによる差分表示

ここからはコード量が増えてきたため、diffコマンドを利用した差分表示でコードの変更箇所を示します。 各行頭の-が削除してほしい行(赤)、+が追加してほしい行(緑)を表しているので、その通りに変更してください。

- 削除してほしい行
+ 追加してほしい行
1
2

@@ -44,9 +50,13 @@のような記述はdiffコマンドによる位置表示の出力であり、数字は気にせず「行のまとまりの区切り」程度に捉えてください。

コードの全体像を確認したい場合は github.com/asa-taka/bootcamp-todo (opens new window) に各ステップのコードがまとめられているため、参考にしたり、どうしても動かなくなった場合はここからコピペをしてリカバリーしてください。

以下のようにApp.tsxValueViewerを追加してください。

@@ -25,6 +25,17 @@
   );
 }
 
+type ValueViewerProps = {
+  value: any;
+};
+
+/** `value`の内容を`JSON.stringify`して表示する、動作確認用コンポーネント。 */
+function ValueViewer({ value }: ValueViewerProps) {
+  return (
+    <pre className="ValueViewer">{JSON.stringify(value, undefined, 2)}</pre>
+  );
+}
+
 /** ToDoリストの初期値。 */
 const INITIAL_TODO: TodoItem[] = [
   { id: 1, text: "todo-item-1", done: false },
@@ -59,6 +70,7 @@
           ))}
         </div>
       )}
+      <ValueViewer value={{ keyword, todoItems, filteredTodoItems }} />
     </div>
   );
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

コード全体: https://github.com/asa-taka/bootcamp-todo/blob/main/src/todo/step2.tsx (opens new window)

ToDoリストの下にJSONが表示されれば成功です😉

ここで定義したValueViewervalueで渡された値を「整形されたJSON」として表示するコンポーネントです。

JSON

JSON (opens new window)はJavaScriptに限らず幅広く利用される構造化データの記述方式です。 雑な表現をすると、ここでは「文字列化されたJavaScriptのオブジェクト」程度に考えてもらえれば十分です。 要は任意のデータを手軽に表示する手段として利用しているだけです。

更に動作を確認するためにフィルタ入力欄を操作してみてください。 フィルタの文字列によってJSON内のfilteredTodoItemsが変わるとともに、表示されるToDoリストの要素が変わると思います。

Reactがデータの変更に対して描画内容をリアクティブに更新するということを、これによりさらに体感してもらえると嬉しいです😉

# データの更新処理(Update)に対応する

ここまではフィルタ処理も含め「閲覧操作」の実装を行なってきました。 しかし、アプリケーションの実装の複雑さはデータの追加や更新などの「変更操作」によって生まれると言えます。

つまりここを理解するには頭を使う必要があるかもしれませんが、理解に取り組んだ分「ちょっとできる人」になれるということです😉

変更操作の手始めとして、CRUD操作ののUpdateに該当する「更新処理」を実装してみましょう。 具体的にはTodoListItemのチェックボックスによりデータのdoneを操作することを可能にします。

App.tsxを以下のように変更してください。

@@ -12,12 +12,18 @@
 
 type TodoListItemProps = {
   item: TodoItem;
+  onCheck: (checked: boolean) => void;
 };
 
 /** ToDoリストの個々のToDoとなるReactコンポーネント。 */
-function TodoListItem({ item }: TodoListItemProps) {
+function TodoListItem({ item, onCheck }: TodoListItemProps) {
   return (
     <div className="TodoItem">
+      <input
+        type="checkbox"
+        checked={item.done}
+        onChange={(ev) => onCheck(ev.currentTarget.checked)}
+      />
       <p style={{ textDecoration: item.done ? "line-through" : "none" }}>
         {item.text}
       </p>
@@ -44,9 +50,15 @@
 
 /** アプリケーション本体となるReactコンポーネント。 */
 export default function App() {
-  const todoItems = INITIAL_TODO;
+  const [todoItems, setTodoItems] = useState(INITIAL_TODO);
   const [keyword, setKeyword] = useState("");
 
+  const updateItem = (newItem: TodoItem) => {
+    setTodoItems(
+      todoItems.map((item) => (item.id === newItem.id ? newItem : item)),
+    );
+  };
+
   const filteredTodoItems = todoItems.filter((item) => {
     return item.text.includes(keyword);
   });
@@ -66,7 +78,13 @@
       ) : (
         <div className="App_todo-list">
           {filteredTodoItems.map((item) => (
-            <TodoListItem key={item.id} item={item} />
+            <TodoListItem
+              key={item.id}
+              item={item}
+              onCheck={(checked) => {
+                updateItem({ ...item, done: checked });
+              }}
+            />
           ))}
         </div>
       )}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

コード全体: https://github.com/asa-taka/bootcamp-todo/blob/main/src/todo/step3.tsx (opens new window)

動作としては前述の通り、チェックボックスによりTodoItemdoneの値が変わり、それが表示に随時反映されていれば成功です😉

ValueViewerの値もぜひ観察してみてください。

チェックボタンが機能することで一気にToDoらしくなりましたね。

いくつか説明が必要な箇所があると思いますので、ここでは一気に説明してしまいます。

  • todoItemsの定義にuseStateを使用した
    • ToDoのリストがが更新された際に表示を更新する必要があるためです
    • つまりAppコンポーネントがtodoItemsというStateを持ったということです
  • TodoListItemonCheckプロパティを追加した
  • AppupdateItemを定義してTodoListItemonCheckに渡した
    • 実装としてはmapメソッドを利用し「idが一致するToDoを引数のnewItemと入れ替えた新しい配列」でStateを更新しています

この中で注目してほしいのは、AppTodoListItemというコンポーネントの親子関係の間でどのように役割が分担されているかということです。

整理するとそれぞれ以下の役割を担っています。

  • 親コンポーネントの役割(ここではApp)
    • Stateの管理(useState)とそれに対する処理(updateItem)を定義し、子コンポーネントにプロパティとして渡す
  • 子コンポーネントの役割(ここではTodoListItem)
    • チェックボックスや入力欄などの「イベントのソースとなる要素」を定義する
    • その要素のイベントに対する処理をプロパティ(onCheck)として親から受け取り、イベントハンドラとして登録する
      • この際、親コンポーネントから扱いやすいように引数や返り値の変換も行なうことが多いです

Reactではこのパターンを基本としてアプリのパーツを作り、それらを組み合わせることで、ブラウザのDOM APIを直接触る場合とは比較にならないくらい複雑で細やかに画面が更新されるアプリを「整理された形で」構築することができます。

Stateの更新時には「入れ物」を更新しよう

todoItems[id] = newItemのように直接代入しないのは「それをするとReactの変更検知がうまく動かない」ためです。 詳しくはstate 内の配列の更新 (opens new window)を見てください。

簡単なルールを紹介すると、配列やオブジェクトをStateで管理し更新する場合は「一番外側の入れ物」は新しいものにする必要があります(この表現でも難しいですよね…)

今回の場合はmapメソッドが新しい配列を返すものであるため、それを利用してStateを更新しています。

# 終了したToDoを非表示にするフィルタ条件を追加する

doneの更新処理が実装できたところで、次にそれに対するフィルタ条件を追加してみましょう。 更新処理の追加に比べれば小さい変更で実装できます。

以下のようにApp.tsxを修正してください。

@@ -52,6 +52,7 @@
 export default function App() {
   const [todoItems, setTodoItems] = useState(INITIAL_TODO);
   const [keyword, setKeyword] = useState("");
+  const [showingDone, setShowingDone] = useState(true);
 
   const updateItem = (newItem: TodoItem) => {
     setTodoItems(
@@ -60,6 +61,7 @@
   };
 
   const filteredTodoItems = todoItems.filter((item) => {
+    if (!showingDone && item.done) return false;
     return item.text.includes(keyword);
   });
 
@@ -72,6 +74,13 @@
           value={keyword}
           onChange={(ev) => setKeyword(ev.target.value)}
         />
+        <input
+          id="showing-done"
+          type="checkbox"
+          checked={showingDone}
+          onChange={(ev) => setShowingDone(ev.target.checked)}
+        />
+        <label htmlFor="showing-done">完了したものも表示する</label>
       </div>
       {filteredTodoItems.length === 0 ? (
         <div className="dimmed">該当するToDoはありません</div>
@@ -88,7 +97,9 @@
           ))}
         </div>
       )}
-      <ValueViewer value={{ keyword, todoItems, filteredTodoItems }} />
+      <ValueViewer
+        value={{ keyword, showingDone, todoItems, filteredTodoItems }}
+      />
     </div>
   );
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

コード全体: https://github.com/asa-taka/bootcamp-todo/blob/main/src/todo/step4.tsx (opens new window)

これにより「完了したものも表示する」というチェックボックスが追加され、そのチェック状態によりその通りリストの表示状態が更新されれば期待通りです😉

やっていることは新しいフィルタ条件用チェックボックスの状態を保持するuseStateと、それを参照するフィルタ関数の処理の追加のみであるため説明は省略します。

# データの追加処理(Create)に対応する

どんどんいきましょう。次は新しくToDoを追加するためのCreateTodoFormを追加しましょう。

以下のようにApp.tsxを編集してください。

@@ -31,6 +31,26 @@
   );
 }
 
+type CreateTodoFormProps = {
+  onSubmit: (text: string) => void;
+};
+
+/** 新しくToDoを追加するためのフォームとなるReactコンポーネント。 */
+function CreateTodoForm({ onSubmit }: CreateTodoFormProps) {
+  const [text, setText] = useState("");
+  return (
+    <div className="CreateTodoForm">
+      <input
+        placeholder="新しいTodo"
+        size={60}
+        value={text}
+        onChange={(ev) => setText(ev.currentTarget.value)}
+      />
+      <button onClick={() => onSubmit(text)}>追加</button>
+    </div>
+  );
+}
+
 type ValueViewerProps = {
   value: any;
 };
@@ -48,12 +68,22 @@
   { id: 2, text: "todo-item-2", done: true },
 ];
 
+/**
+ * ID用途に重複しなさそうな数値を適当に生成する。
+ * 今回は適当にUnix Epoch(1970-01-01)からの経過ミリ秒を利用した。
+ */
+const generateId = () => Date.now();
+
 /** アプリケーション本体となるReactコンポーネント。 */
 export default function App() {
   const [todoItems, setTodoItems] = useState(INITIAL_TODO);
   const [keyword, setKeyword] = useState("");
   const [showingDone, setShowingDone] = useState(true);
 
+  const createItem = (text: string) => {
+    setTodoItems([...todoItems, { id: generateId(), text, done: false }]);
+  };
+
   const updateItem = (newItem: TodoItem) => {
     setTodoItems(
       todoItems.map((item) => (item.id === newItem.id ? newItem : item)),
@@ -97,6 +127,7 @@
           ))}
         </div>
       )}
+      <CreateTodoForm onSubmit={(text) => createItem(text)} />
       <ValueViewer
         value={{ keyword, showingDone, todoItems, filteredTodoItems }}
       />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

コード全体: https://github.com/asa-taka/bootcamp-todo/blob/main/src/todo/step5.tsx (opens new window)

以下のように「新しいToDo」という入力欄が表示され、テキストを入力し追加ボタンを押すと新しいToDoが追加されれば成功です😉

ValueViewerで見てもtodoItemsに新しいToDoが追加されたのがわかると思います。

これで最低限、ToDoアプリとして動作するようになりましたね。

generateIdmap関数で利用するkeyとして使うための値を生成するものです。 他の点は更新処理の実装時と比べて新しいことはしていないため説明を省略します。

スプレッド演算子(`...`)

...は「スプレッド演算子」と呼ばれ、配列の中身を別の配列の中に展開することができます。

const list = [1, 2, 3]
const newList = [...list, 4, 5]
console.log(newList)
// => [ 1, 2, 3, 4, 5 ]
1
2
3
4

同様にオブジェクトに対しても以下のように働きます。

const obj = { a: 12, b: 'foo' }
const newObj = { ...obj, c: 99 }
console.log(newObj)
// => { a: 12, b: 'foo', c: 99 }
1
2
3
4

これらを利用することで他の要素を引き継ぎ、特定の要素のみを更新・追加した、新しい 配列やオブジェクトを作ることができます。

Reactでは先述の通り、変更検知の都合上、状態を更新するときには「入れ物」を新しく作り直す必要があるためこの表現がよく使われます。

参考: スプレッド構文を使ったオブジェクトのコピー (opens new window)

# 状態とそれに対する操作をカスタムHookとしてまとめる

さて、Appコンポーネントの実装が増えてきたため、ある程度の塊で処理を分割したいと思います。

これまでに何度も利用してきましたがuseStateReact Hook (opens new window)と呼ばれるものの一つです。 Hookの詳細は省きますが、ここでは「Reactコンポーネントの中だけで使える特殊な関数」という理解で十分です。 詳細はリンク先を確認してください。

そしてReactではHookをまとめて独自のHookを定義でき、これをカスタムHookと呼びます。 Reactではこれを利用し「状態とそれに対する操作」をまとめてカスタムHookとして定義し、処理をまとめるということをよくやります。

ここではtodoItemsとそれに対する操作をカスタムHookとしてまとめたいと思います。

App.tsxを以下のように変更してください。

@@ -74,6 +74,20 @@
  */
 const generateId = () => Date.now();
 
+/** ToDoのStateとそれに対する操作をまとめたカスタムHook。 */
+const useTodoState = () => {
+  const [todoItems, setTodoItems] = useState(INITIAL_TODO);
+  const createItem = (text: string) => {
+    setTodoItems([...todoItems, { id: generateId(), text, done: false }]);
+  };
+  const updateItem = (newItem: TodoItem) => {
+    setTodoItems(
+      todoItems.map((item) => (item.id === newItem.id ? newItem : item)),
+    );
+  };
+  return [todoItems, createItem, updateItem] as const;
+};
+
 
@@ -84,20 +98,10 @@
 
 /** アプリケーション本体となるReactコンポーネント。 */
 export default function App() {
-  const [todoItems, setTodoItems] = useState(INITIAL_TODO);
+  const [todoItems, createItem, updateItem] = useTodoState();
   const [keyword, setKeyword] = useState("");
   const [showingDone, setShowingDone] = useState(true);
 
-  const createItem = (text: string) => {
-    setTodoItems([...todoItems, { id: generateId(), text, done: false }]);
-  };
-
-  const updateItem = (newItem: TodoItem) => {
-    setTodoItems(
-      todoItems.map((item) => (item.id === newItem.id ? newItem : item)),
-    );
-  };
-
   const filteredTodoItems = todoItems.filter((item) => {
     if (!showingDone && item.done) return false;
     return item.text.includes(keyword);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

コード全体: https://github.com/asa-taka/bootcamp-todo/blob/main/src/todo/step6.tsx (opens new window)

この変更では表示内容は変わりません。

ここではtodoItemscreateItemupdateItemuseTodoStateという名前の関数でまとめました。 このuse***というのは内部でReact Hookを使っていることをわかりやすくするためのただの命名規則で、実質カスタムHookというのはHookを含んでいるだけのただの関数です。

ひとつ注目して欲しいのは、この切り出しによりsetTodoItemsというメソッドがAppコンポーネントからはアクセスできなくなった点です。 setTodoItemstodoItemsに対しては、いわばどのような変更も行える万能メソッドです。 それを隠蔽し、追加や変更など、ある特定の操作に特化したメソッドのみを利用者側に見せることで、そのStateがどのような変更操作を意図しているのかを明確にし、コードの可読性を上げることができます。

「Stateとそれに対する操作をまとめる」というパターンはよく使うため覚えておくと良いでしょう😉

# データの削除処理(Delete)に対応する

最後に残りのCRUD操作である削除(Delete)処理を実装します。 データに対する削除操作は概ね「削除ボタン」として実装されることが多いです。 ここでも「削除ボタン」として実装しましょう。

方針としてはTodoListItemonCheckを追加した時と同じようにonDeleteプロパティを追加します。 以下のようにApp.tsxを修正ししてください。

@@ -13,10 +13,11 @@
 type TodoListItemProps = {
   item: TodoItem;
   onCheck: (checked: boolean) => void;
+  onDelete: () => void;
 };
 
 /** ToDoリストの個々のToDoとなるReactコンポーネント。 */
-function TodoListItem({ item, onCheck }: TodoListItemProps) {
+function TodoListItem({ item, onCheck, onDelete }: TodoListItemProps) {
   return (
     <div className="TodoItem">
       <input
@@ -27,6 +28,9 @@
       <p style={{ textDecoration: item.done ? "line-through" : "none" }}>
         {item.text}
       </p>
+      <button className="button-small" onClick={() => onDelete()}>
+        ×
+      </button>
     </div>
   );
 }
@@ -85,12 +89,15 @@
       todoItems.map((item) => (item.id === newItem.id ? newItem : item)),
     );
   };
+  const deleteItem = (id: number) => {
+    setTodoItems(todoItems.filter((item) => item.id !== id));
+  };
-  return [todoItems, createItem, updateItem] as const;
+  return [todoItems, createItem, updateItem, deleteItem] as const;
 };
 
 /** アプリケーション本体となるReactコンポーネント。 */
 export default function App() {
-  const [todoItems, createItem, updateItem] = useTodoState();
+  const [todoItems, createItem, updateItem, deleteItem] = useTodoState();
   const [keyword, setKeyword] = useState("");
   const [showingDone, setShowingDone] = useState(true);
 
@@ -127,6 +134,7 @@
               onCheck={(checked) => {
                 updateItem({ ...item, done: checked });
               }}
+              onDelete={() => deleteItem(item.id)}
             />
           ))}
         </div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

コード全体: https://github.com/asa-taka/bootcamp-todo/blob/main/src/todo/step-final.tsx (opens new window)

以下のように各ToDoに削除ボタンがついて、ボタンを押してその項目が削除されれば想定通りです😉

ValueViewertodoItemsを見てもそのデータが消えているのがわかると思います。

以上でToDoアプリの一通りの実装ができました。やったね。

# 🚩チェックポイント

ここまでで、以下のReactにおける実装パターンを学べていると良いでしょう。

  • CRUD操作のそれぞれに対応する実装パターン
    • ここで紹介した以外にもたくさんの実装パターンがあります
  • Stateとそれに対する操作手段をカスタムHookとしてまとめる
  • 親コンポーネントではStateとそれに対する操作手段を、子コンポーネントではイベントのソースとなる要素とそのハンドラへの処理の登録をそれぞれ分担する
    • ただしなんでもかんでも親がStateを持てばいいというわけではないのが難しいところです

⛳️通常のゴール

もし2時間でここまでついて来れていたら大したものです。お疲れ様でした😉

# 発展課題

時間に余裕があれば挑戦してみてください。 この先の「APIサーバとのやりとりを行うToDoアプリ」に進んでも構いません。

  • localStorage (opens new window)を利用してリロードしても変更した内容が保たれるようにしよう
    • 現在の実装ではリロードすると値が消えてしまいますが、ブラウザにはlocalStorageという値の保存場所があります
    • localStorageには文字列しか格納できないため、任意のデータを保存する場合は、保存する前にはJSON.stringifyで文字列にし、取り出した後にはJSON.parseでJavaScriptの値(オブジェクト・数値・文字列・etc)として変換します
      • こういう文字列変換する処理をシリアライズ、その逆変換をデシリアライズという言います
    • これができると、自分の手元で動作すればいいだけのアプリであればグッと実用性が増します
  • CreateItemFormの入力後に入力欄の値がクリアされるようにしてみよう
    • この方が使用感は上がると思います
  • 削除処理の前に確認メッセージを入れてみよう
    • window.confirm (opens new window)というブラウザ標準のAPIを使うと割と簡単に実装できます
    • ただしToDoアプリ程度だと、毎回確認が入ると使用感が落ちるかもしれないですね
  • その他、好きに機能を拡張してみよう
    • ToDoに締切を入力できるようにする、など自由に機能を拡張してください

まだまだ序の口 その1 - CRUD操作の奥深さ

今回の講義ではシングルページで組むこととしたため、CRUD操作の実装パターンや悩みどころについては触れられていない部分が多いです。 実際React Router (opens new window)などを使いマルチページにすることで、イベントハンドラでの連携や各操作のAPIレスポンスとして返されると嬉しい値など、ノウハウの貯めどころは格段に増えてきます。

ただし大きなアプリの機能の一部として「リストをページ遷移なしで編集する」というケースは存在するため、その点ではToDoアプリも十分に応用しがいのある実装例と言えます。

まだまだ序の口 その2 - 実際のアプリに必要な要素

ToDoアプリは基礎の一要素にはなりますが、より実践的なアプリを組む場合には不足している要素がたくさんあります。

実践を考えると、例えば以下の要素が不足していると考えられます。

  • サーバとのやりとり
    • ブラウザを跨いだデータの永続化に必要になります
    • ネットワーク越しにAPIを利用してCRUDやその他の操作を行う必要が出てきます
    • Promiseなどの非同期処理の理解やHTTPについての理解も必要になります
  • 認証
    • 社内で業務上の情報を扱うツールを作る場合は避けられないでしょう
  • ルーティングライブラリを利用したマルチページ化(react-routerなど)
    • 擬似的に複数ページのアプリケーションを表現する技術です
    • アプリ開発の世界ではURLと画面の対応付けのことを「ルーティング」と呼び、IPルーティングとは異なる概念です

最近のReact公式のチュートリアル (opens new window)ではNextJS (opens new window)という統合的なフレームワークがプロジェクトを始める際の第一の選択肢として挙げられており、これは上に挙げた要素を含むものになっているため、触ってみると良いかもしれません(まだきちんと触ったことはないので自信はないです)。

また、ToDoMVCのさらに発展版のような、RealWorld (opens new window)という、より実践的な要素を網羅した実装例をまとめたサイトもあるため、参考にするといいかもしれません(こちらも私はあまり見たことはないです)。

# APIサーバとのやりとりを行うToDoアプリ

エクストラステージへようこそ

ここからはほとんどの人は2時間では到達できないと思いますが、駆け足で進められた人への暇つぶしとして書いておきます😉

ここまではブラウザ内で完結した処理のみを実装してきましたが、ここからはさらに実践的な例としてサーバとのやり取りを想定した実装に挑戦してみましょう。 一般的にウェブアプリといえば、ほぼこの形態です。 サーバに対してデータを保存することができれば、別のPCや他の人が同じ情報にアクセスできます。 ここまでできると、ちょっとしたSaaS(Software as a Service)ですね。

コードの量が増えるためファイルを2つに分けます。 まず、APIクライアントの定義である、以下の内容のapi.tsxApp.tsxと同じ階層(srcの直下)に新たに作成してください。 このようなコードは実際の開発ではAPI定義より自動生成する場合もあるため、コピペで作成しても大丈夫です。

/** 個々のToDoを表す型。*/
export type TodoItem = {
  /** 全てのToDoで一意な値 */
  id: number;
  /** ToDoの内容 */
  text: string;
  /** 完了すると`true`となる。 */
  done: boolean;
};

/**
 * ブラウザ上で動作するAPIクライアントのモック(=それらしく動くもの)。
 * 今回はあまり内部の処理について理解する必要はなく、ToDoの配列を保持して
 * それを操作するためのメソッドを提供している、という程度の理解で十分。
 *
 * ネットワーク越しのリクエストを再現するため、各メソッドには遅延時間を設けている。
 */
export class TodoApiMock {
  constructor(private todoItems: TodoItem[]) {}

  /** 条件に該当するToDoを配列で返す。 */
  async queryItems(keyword: string, includeDone: boolean) {
    await this.simulateNetworkDelay();
    return this.todoItems.filter((item) => {
      if (!includeDone && item.done) return false;
      return keyword ? item.text.includes(keyword) : true;
    });
  }

  /** 新しくToDoを作成する。 */
  async createItem(text: string) {
    await this.simulateNetworkDelay();
    const newItem = { id: this.generateId(), text, done: false };
    this.todoItems.push(newItem);
    return newItem;
  }

  /** 既存のToDoを置き換える。 */
  async updateItem(newItem: TodoItem) {
    await this.simulateNetworkDelay();
    this.todoItems = this.todoItems.map((item) =>
      item.id === newItem.id ? newItem : item,
    );
  }

  /** 既存のToDoを削除する。 */
  async deleteItem(id: number) {
    await this.simulateNetworkDelay();
    this.todoItems = this.todoItems.filter((item) => item.id !== id);
  }

  private simulateNetworkDelay() {
    return new Promise((resolve) => setTimeout(resolve, 500));
  }

  /** ID用途に重複しなさそうな数値を適当に生成する。 */
  private generateId() {
    // モックなので適当に1970-01-01からの経過ミリ秒を利用した
    return Date.now();
  }
}

/**
 * APIサーバに対してリクエストを行う実際のAPIクライアント。
 * REST風のAPIを想定している。
 *
 * ここではブラウザ標準の`fetch`を利用しているが、[Axios](https://www.npmjs.com/package/axios)
 * というライブラリを使うとこれよりも楽に書け、特にJSONの扱いが便利になる。
 */
export class TodoApiClient {
  /**
   * @example
   * new TodoApiClient('http://localhost:8080')
   */
  constructor(private baseUrl: string) {}

  /** 条件に該当するToDoを配列で返す。 */
  async queryItems(keyword: string, includeDone: boolean) {
    const url = new URL(`${this.baseUrl}/todo`);
    if (keyword !== "") {
      url.searchParams.set("keyword", keyword);
    }
    if (includeDone) {
      url.searchParams.set("include_done", "true");
    }
    return fetch(url).then((res) => res.json());
  }

  /** 新しくToDoを作成する。 */
  async createItem(text: string) {
    return fetch(`${this.baseUrl}/todo`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text }),
    }).then((res) => res.json());
  }

  /** 既存のToDoを置き換える。 */
  async updateItem(newItem: TodoItem) {
    return fetch(`${this.baseUrl}/todo/${newItem.id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(newItem),
    }).then((res) => res.json());
  }

  /** 既存のToDoを削除する。 */
  async deleteItem(id: number) {
    return fetch(`${this.baseUrl}/todo/${id}`, { method: "DELETE" }).then(
      (res) => res.json(),
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113

このファイルはTodoApiClientTodoApiMockを主に定義しています。

TodoApiClientは実際にサーバにアクセスするクライアントクラスです。 TodoApiMockはブラウザ上で処理が完結している「それらしく動作する」モック(=クライアントもどき)です。 実際の開発でもモックを利用しながらウェブアプリの実装を進め、ある程度形になったところで実際のサーバにアクセスし、動作を結合させたりします。

次にApp.tsxを以下のように丸ごと書き換えてください。

import { useEffect, useState } from "react";

import { TodoItem, TodoApiMock, TodoApiClient } from "./api";

const INITIAL_TODO: TodoItem[] = [
  { id: 1, text: "todo-item-1", done: false },
  { id: 2, text: "todo-item-2", done: true },
];

/** モックと実際のAPIクライアントを切り替えるためのコメントアウト */
const todoApi = new TodoApiMock(INITIAL_TODO);
// const todoApi = new TodoApiClient('http://localhost:8080')

type TodoListItemProps = {
  item: TodoItem;
  onCheck: (checked: boolean) => void;
  onDelete: () => void;
};

function TodoListItem({ item, onCheck, onDelete }: TodoListItemProps) {
  return (
    <div className="TodoItem">
      <input
        type="checkbox"
        checked={item.done}
        onChange={(ev) => onCheck(ev.currentTarget.checked)}
      />
      <p style={{ textDecoration: item.done ? "line-through" : "none" }}>
        {item.text}
      </p>
      <button className="button-small" onClick={() => onDelete()}>
        ×
      </button>
    </div>
  );
}

type CreateTodoFormProps = {
  onSubmit: (text: string) => void;
};

function CreateTodoForm({ onSubmit }: CreateTodoFormProps) {
  const [text, setText] = useState("");
  return (
    <div className="CreateTodoForm">
      <input
        placeholder="新しいTodo"
        size={60}
        value={text}
        onChange={(ev) => setText(ev.currentTarget.value)}
      />
      <button onClick={() => onSubmit(text)}>追加</button>
    </div>
  );
}

type ValueViewerProps = {
  value: any;
};

function ValueViewer({ value }: ValueViewerProps) {
  return (
    <pre className="ValueViewer">{JSON.stringify(value, undefined, 2)}</pre>
  );
}

export default function App() {
  const [todoItems, setTodoItems] = useState<TodoItem[] | null>(null);
  const [keyword, setKeyword] = useState("");
  const [showingDone, setShowingDone] = useState(false);

  const reloadTodoItems = async () => {
    setTodoItems(await todoApi.queryItems(keyword, showingDone));
  };

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

  return (
    <div className="App">
      <h1>ToDo</h1>
      <div className="App_todo-list-control">
        <input
          placeholder="キーワードフィルタ"
          value={keyword}
          onChange={(ev) => setKeyword(ev.target.value)}
        />
        <input
          id="showing-done"
          type="checkbox"
          checked={showingDone}
          onChange={(ev) => setShowingDone(ev.target.checked)}
        />
        <label htmlFor="showing-done">完了したものも表示する</label>
        <button onClick={() => reloadTodoItems()}>更新</button>
      </div>
      {todoItems === null ? (
        <div className="dimmed">データを取得中です...</div>
      ) : todoItems.length === 0 ? (
        <div className="dimmed">該当するToDoはありません</div>
      ) : (
        <div className="App_todo-list">
          {todoItems.map((item) => (
            <TodoListItem
              key={item.id}
              item={item}
              onCheck={async (checked) => {
                await todoApi.updateItem({ ...item, done: checked });
                reloadTodoItems();
              }}
              onDelete={async () => {
                await todoApi.deleteItem(item.id);
                reloadTodoItems();
              }}
            />
          ))}
        </div>
      )}
      <CreateTodoForm
        onSubmit={async (text) => {
          await todoApi.createItem(text);
          reloadTodoItems();
        }}
      />
      <ValueViewer value={todoItems} />
    </div>
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129

実装としてはかなり複雑になったことがわかると思います。

主な変更点としては以下の通りです。

  • useTodoStateの代わりにtodoApiのメソッドを利用している
    • つまりtodoItemsの管理の主体がコンポーネントからサーバに移動したということです
      • ただし取得したデータを保持するためにuseStateを利用しています
    • ここから先は常に「マスターデータはサーバにある」という認識を持つのが良いでしょう
      • 言い換えるとウェブアプリが持っているのはあくまで「ある瞬間にサーバから取得したデータのコピーである」ということです
  • async/awaitによる非同期処理に対応している
  • useEffectにより初回のデータ取得を自動でおこなっている
  • 値を追加・更新・削除した際にリストを再取得している
    • リストを取得する際に条件を与えているため、値の変更後にその条件にマッチした要素は何か、クライアント側では判断がつかないためリストを再取得する必要があります
    • これも「マスターデータはサーバにある」の一種と言えます

しかし、ここまで来れた人に多くは説明しません。 というより申し訳ないのですが、資料を用意している時間がもうないためここから先は好きなように進めてください。 あとついでにですが、もとのToDoアプリのコードとのつながりを優先したせいで、コンポーネントが妥当な機能の切り分けになっていないと思います。

「こうしたらいいんじゃないかな」という点は次の発展課題に上げておいたので、どうかいい感じにしてやってください…🥺すみません…

# サーバを立ててアクセスしてみる(Vite環境のみ)

残念ながらCodeSandboxでは実行できないためモックで我慢してください🥺

APIサーバのコンテナイメージを用意してありますので、以下のようにローカルでサーバを立ててサーバへのアクセスを実際に試してみてください。

docker run --rm -p 8080:8080 ghcr.io/asa-taka/bootcamp-todo-api --port=8080 --host=0.0.0.0
1

これを実行するとhttp://localhost:8080 (opens new window)でAPIサーバが動作します。 試しにブラウザでhttp://localhost:8080/todo (opens new window)を表示するとToDoのデータのJSONが表示されれば成功です😉

この状態でコード中の、モックと切り替えるためのコメントアウトを入れ替えると、ウェブアプリからもアクセスされるようになるはずです。

// const todoApi = new TodoApiMock(INITIAL_TODO);
const todoApi = new TodoApiClient('http://localhost:8080')
1
2

# 発展課題

時間に余裕があれば挑戦してみてください。

  • 更新中の状態をユーザに伝えたい
  • 条件を変更した時に自動でリストを更新したい
  • apiを呼ぶ処理はTodoItemの方に寄せてもいい
    • reloadする処理をどうするか
  • エラーレスポンスに対してはどうするか

ただし、実際にはこのあたりの実装はreact-query (opens new window)などのパッケージを利用することが多いです。

# アプリをビルドしてコンテナ化する(Vite環境のみ)

発展課題のついでのおまけのおまけです。アプリをビルドしてついでにコンテナ化しましょう😉

ウェブアプリケーションの「ビルド」とは ブラウザが解釈できる純粋なHTMLとCSSとJavaScript をソースコードから生成することを指します。

プロジェクトのルートディクトリ(フォルダの一番上の階層)に移動して、以下の内容でDockerfile という名前のファイルを作ってください。

FROM nginx:1.25.2
COPY ./dist /usr/share/nginx/html
1
2

フォルダ内の構成はこのようになります。

(ルートディレクトリ)
├── Dockerfile
├── src
└── (その他のファイル)
1
2
3
4

この状態で、同じくルートディレクトリで以下のコマンドを実行することで、コンテナイメージを作成することができます(ちょっと雑な作り方です)。

# distフォルダにビルドされたウェブアプリが生成される。
npm run build

# distフォルダを取り込んだnginx(ウェブサーバ)のコンテナイメージを作成する。
docker build . -t todo-web

# コンテナを動かし9000番ポートでウェブアプリを公開する。
docker run --rm -p 9000:80 todo-web
1
2
3
4
5
6
7
8

この状態でhttp://localhost:9000 (opens new window)にアクセスして、今までと同様のウェブアプリが表示されれば成功です😉

アプリをコンテナイメージとしてまとめることで、利用者がアプリを動かすためのセットアップが簡単にできたり、Kubernetes環境でアプリを動作させることができます。

えっ、ここまでできたんですか?

ちょっと、できる人すぎですよ😉

# 参考になるサイト

# React

# JavaScript

# TypeScript

以上で講義は終わりです。よいReactライフを😉


CC BY-SA Licensed | Copyright (c) 2020, Internet Initiative Japan Inc.