【VSCode】ReactとTypeScriptでTODOリストを作って基礎を学ぶ
今回はReactとTypeScriptでTODOリストを作ってReactの基礎を学びます。
作成するデモサイトは以下です。
前提条件は以下
- creat-react-appを使ってReact、TypeScriptの環境で行う
- ESLint(Airbnb)とPrettierを追加して、VSCode上の拡張機能と連動させる
環境は以下です。
- macOS Catalina v10.15.5
- Visual Studio Code v1.57.0
- React v17.0.2
- TypeScript v4.5.5
- node.js v16.13.1
どんなTODOリストを作るか説明
作りたいTODOリストは以下の通り。
①では、文字を入力してEnterキーを押すことで②のTODOリストに追加。
②では、追加されたTODOリストをクリックするとチェックマークが入り、文字に打ち消し線が入る。チェックマークが入ると「○個のタスクが残っています」から数が減っていく。
チェックマークが入ったTODOリストは、「完了済を削除」ボタンをクリックすることで③に移動。チェックマークが入っている有無に関わらず、削除したい場合は「やることをクリア」ボタンをクリックしてリストから削除。
③では、②で「完了済を削除」ボタンを押して削除されたTODOリストが表示される。
VSCodeで開発環境を作る
VSCode上でReactとTypeScript、ESLint、Prettierで開発環境を作ります。作り方は以下の記事にまとめているので参考にしてみてください。
ディレクトリの構成について
開発環境が作れたらディレクトリの構成を整理しましょう。最終的には以下のようになります。今回は単純なアプリなのでシンプルな構成になりましたが、ディレクトリの構成はチームによって様々です。ディレクトリ構成についてはatomic designなどがありますが、あまり複雑にならないようにしましょう。
|_public/
| |- index.html
|
|_src/
|- assets/ (共通で使うCSSや画像をまとめている)
| |- css/
| | |- _base.scss
| | |- _custom-property.scss
| | |- styles.scss
| |
| |- img/
|
|- components/(コンポーネントをまとめている)
| |- blocks/(ページを構成するコンポーネント)
| | |- TodoAdd.module.scss
| | |- TodoAdd.tsx
| | |- TodoDones.module.scss
| | |- TodoDones.tsx
| | |- TodoInput.module.scss
| | |- TodoInput.tsx
| | |- TodoItem.module.scss
| | |- TodoItem.tsx
| |
| |- pages/(全ページを管理する)
| | |- home/(トップページ)
| | | |- Todo.module.scss
| | | |- Todo.tsx
| |
| |- types/(共通で使う型をまとめている)
| | |- item.ts
|
|- App.tsx
|- index.tsx
それぞれのディレクトリとファイルの役割を説明します。
publicの役割
index.htmlを開くと以下のようにid名rootがあって、その中にsrcディレクトリにあるindex.tsxの内容が出力されます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Web site created using create-react-app" />
<title>React App</title>
</head>
<body>
<div id="root"></div> <!-- この中にコンポーネントが出力される -->
</body>
</html>
srcの役割
srcディレクトリがReactで作業するエリアになります。srcディレクトリには以下のディレクトリとファイルがあります。
- assets … サイト全体に共通するCSSや画像を管理するディレクトリ
- components … blocks、pages、typesのディレクトリが存在します。それぞれの役割については後ほど説明します。
- App.tsx … このファイルにcomponentsディレクトリにある情報を読み込ませて、最終的にindex.tsxに読み込ませます。
- index.tsx … App.tsxを読み込み、読み込んだ情報をindex.htmlに出力します。
blocksのディレクトリ
今回のTODOアプリだと、ざっくり3つのエリアが存在します。そしてこの3つのエリアごとにファイルを分割してblocksディレクトリで管理しています。
①のTODOを入力するエリア
TodoInput.tsxとTodoInput.module.scssで構成
②のTODOの状態を管理するエリア
TodoAdd.tsxとTodoAdd.module.scss、TodoItem.tsxとTodoItem.module.scssで構成
③のTODOの完了済みを表示するエリア
TodoDones.tsxとTodoDones.module.scssで構成
ReactではCSSの指定する方法が複数ありますが、今回はCSS Modulesを使っています。例えば①のTODOを入力するエリアのTodoInput.tsxについては、TodoInput.module.scssのファイルにSassを使って書いていきます。必ず【対応するファイル名 + .module.scss】の名前にすることがポイントです。
pagesのディレクトリ
ページごとにpagesディレクトリで管理します。今回はトップページだけなので、pagesの中にhomeディレクトリを用意して、トップページに必要なコンポーネントをblocksディレクトリから読み込んでいます。
今回だとTodo.tsxがトップページ用のファイルになるので、それをApp.tsxに読み込ませて表示さます。
typesのディレクトリ
TODOアプリ全体で使用する型を定義しておく場所です。同じ型を複数のコンポーネントで使用する場合、1箇所で管理できるようにしておきます。
今回はitem.tsというファイルを作成して共通で使う型を書いておきます。
export type Item = {
key: string;
text: string;
done: boolean;
};
基準となるindex.tsxのコード
index.tsxの役割は、まとめられたコンポーネントを読み込み、index.htmlやcssに出力することです。今回書くコードは以下。
import React from 'react';
import ReactDOM from 'react-dom';
import './assets/css/styles.scss'; //基本となるstyles.scssを読み込み
import App from './App'; //App.tsxの読み込み
ReactDOM.render(
<React.StrictMode>
<App /> {/* App.tsxを読み込んでいる */}
</React.StrictMode>,
document.getElementById('root')
);
4行目で基本となるcssを読み込んでいます。リセットcssやbodyに指定するcssです。6行目でApp.tsxを読み込み、10行目でAppコンポーネントを指定しています。
では読み込んだApp.tsxにはどんなコードが書かれているか見てみます。
App.tsxのコード
App.tsxでは今回のTODOアプリに必要なTodo.tsxを読み込んでいます。今回は1ページだけなのでこれだけしかコードがないですが、複数ページがあるとApp.tsxを軸にページを切り替える設定を書くことが多いです。
import { Todo } from './components/pages/home/Todo'; //Todo.tsxの読み込み
const App = () => <Todo />; //Todoコンポーネントを指定
export default App;
ではTodo.tsxにはどんなコードが書かれているか見てみます。
Todo.tsxのコード
Todo.tsxはTODOアプリ全体を総括しています。blocksディレクトリに分割していた3つのコンポーネントを読み込んで1つのTODOアプリを表示させています。
import { useCallback, useState } from 'react';
import classes from './Todo.module.scss';
import { Item } from '../../types/item';
import { TodoInput } from '../../blocks/TodoInput'; //コンポーネント読み込み
import { TodoAdd } from '../../blocks/TodoAdd'; //コンポーネント読み込み
import { TodoDones } from '../../blocks/TodoDones'; //コンポーネントの読み込み
const getKey = () => Math.random().toString(32).substring(2); // 0〜1未満の乱数字を取得して、数字を32進法に文字列に変換。前から3番目から文字を抽出
export const Todo = () => {
const [items, setItems] = useState<Array<Item>>([{ key: getKey(), text: 'これはダミーのTODOです', done: false }]);
const [itemsDone, setItemsDone] = useState<Array<Item>>([]);
const [text, setText] = useState<string>('');
const [typing, setTyping] = useState<boolean>(false);
const onAdd = useCallback(
(inputText: string) => {
setItems([...items, { key: getKey(), text: inputText, done: false }]);
},
[items]
);
return (
<div className={classes.container}>
<div className={classes.inner}>
<div className={classes.main}>
<h1 className={classes.heading}>やることリスト</h1>
<TodoInput onAdd={onAdd} text={text} setText={setText} typing={typing} setTyping={setTyping} />
<TodoAdd items={items} setItems={setItems} itemsDone={itemsDone} setItemsDone={setItemsDone} />
</div>
<TodoDones itemsDone={itemsDone} />
</div>
</div>
);
};
4つのstateで状態を管理する
状態の管理としては4つのstateを用意しました。
const [items, setItems] = useState<Array<Item>>([{ key: getKey(), text: 'これはダミーのTODOです', done: false }]);
const [itemsDone, setItemsDone] = useState<Array<Item>>([]);
const [text, setText] = useState<string>('');
const [typing, setTyping] = useState<boolean>(false);
itemsには配列の中にオブジェクトを用意して、keyとtext、doneのプロパティを持つようにしています。keyはTODOリスト1つ1つを判別するために、textはTODOリストの名前、doneは完了したタスクかどうかfalse、trueで判断するために使います。
itemsDoneは完了したTODOリストの管理用です。初期値として空の配列を用意しています。
textはinput部分で入力した文字の管理用です。初期値は空の文字列を設定しています。
typingはinput部分でテキストの入力中なのか、テキスト入力確定済みかの判定用です。
本来はReduxやRecoilを使って状態を管理することが理想ですが、今回は基礎なので使用しません。Redux、Recoilの使い方は以下をどうぞ。
TODOリストを追加する関数
以下のonAdd関数はinput要素にテキストを入力して、TODOリストに追加するときに実行される関数です。useCallbackを使って[items]を指定しているのは、itemsのstateが変更されたときだけ再計算させるようにするためです。
const onAdd = useCallback(
(inputText: string) => {
setItems([...items, { key: getKey(), text: inputText, done: false }]);
},
[items]
);
このonAdd関数はTodo.tsxで実行されるのではなく、子コンポーネントであるTodoInputコンポーネントで実行させたいので、以下のようにonAddという名前で関数を渡しています。
<TodoInput onAdd={onAdd} text={text} setText={setText} typing={typing} setTyping={setTyping} />
子コンポーネントにstateや関数を渡す
Todo.tsxには3つのコンポーネントを読み込んでいます。そしてそのコンポーネントに必要なstateをそれぞれ渡しています。
- TodoInputコンポーネント
- TodoAddコンポーネント
- TodoDonesコンポーネント
return (
<div className={classes.container}>
<div className={classes.inner}>
<div className={classes.main}>
<h1 className={classes.heading}>やることリスト</h1>
<TodoInput onAdd={onAdd} text={text} setText={setText} typing={typing} setTyping={setTyping} />
<TodoAdd items={items} setItems={setItems} itemsDone={itemsDone} setItemsDone={setItemsDone} />
</div>
<TodoDones itemsDone={itemsDone} />
</div>
</div>
);
それでは親コンポーネントから渡されたstateや関数は、子コンポーネントでそのように使うのか見てみましょう。
TodoInput.tsxのコード
TodoInputコンポーネントは以下のinput部分の設定になります。
親コンポーネントから渡されたstateと関数は以下の5つです。子コンポーネントはそれをpropsとして受け取り、自分のコンポーネント内で使用します。
- text
- setText
- typing
- setTyping
- onAdd
import { ChangeEvent, KeyboardEvent, memo, useCallback, VFC } from 'react';
import classes from './TodoInput.module.scss';
type Props = {
text: string;
setText: React.Dispatch<React.SetStateAction<string>>;
typing: boolean;
setTyping: React.Dispatch<React.SetStateAction<boolean>>;
onAdd: (text: string) => void;
};
export const TodoInput: VFC<Props> = memo((props) => {
const { text, setText, typing, setTyping, onAdd } = props;
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setText(e.target.value), [setText]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!text) return;
if (e.key !== 'Enter' || typing) return;
onAdd(text);
setText('');
};
return (
<div className={classes.block}>
<input
className={classes.input}
type="text"
placeholder="Enterで入力する"
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => setTyping(true)}
onCompositionEnd={() => setTyping(false)}
/>
</div>
);
});
propsで受け取ったstateや関数は使いやすくするために以下のように分割代入してます。
const { text, setText, typing, setTyping, onAdd } = props;
propsの前にmemoとあるのは、受け取ったstateに変化が無ければTodoInputコンポーネントを再レンダリングさせないためです。
親コンポーネントであるTodo.tsxにstateが定義されていて、1つのstateでも変更されると、親で読み込まれている子コンポーネントはすべて再レンダリングされます。変化がないstateを含む子コンポーネントも再レンダリングされるとパフォーマンスが下がるので、memoを指定してそれを防ぎます。
また、VFC<Props>とはFunction Componentを定義するための型で、<Props>の部分でpropsで受け取った値の型を指定しています。
VFCについては以下がわかりやすいです。
input要素に入力されたテキストを管理する関数
input要素にテキストを1文字ずつ打つたびにsetTextに反映させています。
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setText(e.target.value), [setText]);
テキストが入力完了したか判定する関数
input要素に何も文字が無ければreturnさせます。そしてキーボードの入力がEnterキーで、かつtypingがfalseの場合はonAdd関数は実行されます。こうすることで半角入力後にEnterキーを押した場合と、全角変換後にEnterキーを押した場合にだけTODOリストに追加できるようになります。
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!text) return;
if (e.key !== 'Enter' || typing) return;
onAdd(text);
setText('');
};
typingをtrueにするかfalseにするかはinputの中に設定されているonCompositionStartとonCompositionEndで切り替えています。
全角入力開始にはonCompositionStartメソッドが発火するのでそのときにsetTypingをtrueします。全角入力完了後にはonCompositionEndメソッドが発火するのでそのときにsetTypingをfalseにしています。
<input
className={classes.input}
type="text"
placeholder="Enterで入力する"
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onCompositionStart={() => setTyping(true)}
onCompositionEnd={() => setTyping(false)}
/>
TodoAdd.tsxのコード
TodoAddコンポーネントは以下のエリアになります。
親コンポーネントからは4つのstateを受け取っています。
- items
- setItems
- itemsDone
- setItemsDone
また子コンポーネントとしてTodoItemを読み込んでいます。
- TodoItemコンポーネント
import { memo, useCallback, VFC } from 'react';
import classes from './TodoAdd.module.scss';
import { Item } from '../types/item';
import { TodoItem } from './TodoItem';
type Props = {
items: Array<Item>;
setItems: React.Dispatch<React.SetStateAction<Item[]>>;
itemsDone: Array<Item>;
setItemsDone: React.Dispatch<React.SetStateAction<Item[]>>;
};
export const TodoAdd: VFC<Props> = memo((props) => {
const { items, setItems, itemsDone, setItemsDone } = props;
const onCheckChange = useCallback(
(checkedItem: Item) => {
// mapは配列の中の要素を1つずつ取り出し、処理後に新しい配列を作る
const newItems = items.map((item) => {
// checkされたinputにあるkeyと、stateにあるitemのkeyが一致するなら、doneのfalseをtrueにして、変更したitemをsetItemsに指定する
if (item.key === checkedItem.key) {
item.done = !item.done;
}
return item;
});
setItems(newItems);
},
[items, setItems]
);
const TodoDoneLength = useCallback(() => {
const newItemsDone = items.filter((item) => item.done === false);
if (newItemsDone.length)
return <span className={classes.todoLength_alert}>{`${newItemsDone.length}個のタスクが残っています`}</span>;
return 'タスクはありません';
}, [items]);
const onClickDelete = () => {
if (!items.length) return;
const newItems = items.filter((item) => item.done === false);
setItems(newItems);
const newItemsDone = items.filter((item) => item.done === true);
setItemsDone([...itemsDone, ...newItemsDone]);
};
const onClickAllClear = () => {
const newItems: Array<Item> = [];
setItems(newItems);
};
return (
<div className={classes.block}>
<div className={classes.blockUpper}>
<div className={classes.todoLength}>{TodoDoneLength()}</div>
{items.map((item) => (
<TodoItem key={item.key} item={item} onCheck={onCheckChange} />
))}
</div>
<div className={classes.blockBottom}>
<button className={classes.button} onClick={onClickDelete} type="button">
完了済を削除
</button>
<button className={`${classes.button} ${classes.button_clear}`} onClick={onClickAllClear} type="button">
やることをクリア
</button>
</div>
</div>
);
});
一番ボリュームが多いコンポーネントですが、1つずつ見ていきます。
チェックが入ったTODOリストを管理する関数
onCheckChange関数はチェックが入っているTODOリストと現状のTODOリストを比べて、keyが一致したTODOリストに対して、プロパティのdoneの値を切り替えています。falseであればtrueに、falseであればtrueにしています。
この関数は子であるTodoItemコンポーネントで実行されます。
const onCheckChange = useCallback(
(checkedItem: Item) => {
// mapは配列の中の要素を1つずつ取り出し、処理後に新しい配列を作る
const newItems = items.map((item) => {
// checkされたinputにあるkeyと、stateにあるitemのkeyが一致するなら、doneのfalseをtrueにして、変更したitemをsetItemsに指定する
if (item.key === checkedItem.key) {
item.done = !item.done;
}
return item;
});
setItems(newItems);
},
[items, setItems]
);
チェックが入ったTODOの数をカウントする関数
TODOリストのチェックが入るとタスクが完了したことになり、右上にある「○個のタスクが残っています」の○個の数が減っていきます。
itemsの中からプロパティdoneの値がfalseのものだけfilterで取り出して配列にしたら、lengthで数を取得します。falseが1つも無ければ「タスクはありません」と表示させます。
const TodoDoneLength = useCallback(() => {
const newItemsDone = items.filter((item) => item.done === false);
if (newItemsDone.length)
return <span className={classes.todoLength_alert}>{`${newItemsDone.length}個のタスクが残っています`}</span>;
return 'タスクはありません';
}, [items]);
完了済みのTODOリストのみ削除する関数
「完了済を削除」ボタンをクリックすると、TODOリストでチェックマークが入っているものを削除して、【完了済 一覧】エリアに移します。
itemsが1つもなければreturnされます。
itemsのプロパティdoneの値がfalseのものをfilterで取り出して配列にしたら、setItemsにセット。これでチェックマークが入っていないTODOリストのみ表示されます。
doneの値がtrueのものは、setItemsDoneにすでにあるitemsDoneと一緒に配列としてセット。これで【完了済 一覧】エリアにチェックマークが入っていたTODOリストのみ表示されます。
const onClickDelete = () => {
if (!items.length) return;
const newItems = items.filter((item) => item.done === false);
setItems(newItems);
const newItemsDone = items.filter((item) => item.done === true);
setItemsDone([...itemsDone, ...newItemsDone]);
};
TODOリストすべてを削除する関数
「やることをクリア」ボタンをクリックすると、TODOリストすべてを削除します。setItemsに空の配列をセットすることで実現できます。
const onClickAllClear = () => {
const newItems: Array<Item> = [];
setItems(newItems);
};
受け取ったstateや定義した関数をセットする
親コンポーネントから受け取ったstateや定義した関数をセットしていきます。
5〜7行目ではitemsの中にある要素の数だけmapを使ってループさせ、TodoItemコンポーネントを出力しています。Reactでmapを使ってループさせるとき、必ず目印となるkeyを設定する必要があります。
return (
<div className={classes.block}>
<div className={classes.blockUpper}>
<div className={classes.todoLength}>{TodoDoneLength()}</div>
{items.map((item) => (
<TodoItem key={item.key} item={item} onCheck={onCheckChange} />
))}
</div>
<div className={classes.blockBottom}>
<button className={classes.button} onClick={onClickDelete} type="button">
完了済を削除
</button>
<button className={`${classes.button} ${classes.button_clear}`} onClick={onClickAllClear} type="button">
やることをクリア
</button>
</div>
</div>
);
それではTodoItemsコンポーネントにはどんなコードが書かれているのでしょうか。
TodoItem.tsxのコード
親コンポーネントであるTodpAddからは2つの値をpropsで受け取っています。
- item
- onCheck
import { memo, VFC } from 'react';
import classes from './TodoItem.module.scss';
import { Item } from '../types/item';
type Props = {
item: Item;
onCheck: (checkedItem: Item) => void;
};
export const TodoItem: VFC<Props> = memo((props) => {
const { item, onCheck } = props;
const handleChange = () => onCheck(item);
return (
<>
{/* eslint-disable jsx-a11y/label-has-associated-control */}
<label className={classes.block}>
<span className={item.done ? `${classes.text_done} ${classes.text}` : `${classes.text}`}>{item.text}</span>
<input type="checkbox" checked={item.done} onChange={handleChange} />
</label>
{/* eslint-disable jsx-a11y/label-has-associated-control */}
</>
);
});
inputのチェックマークが更新されるとhandleChange関数が実行され、親コンポーネントから受け取ったonCheck関数が実行されます。onCheck関数のパラメーターには親コンポーネントから受け取ったitemが設定されています。
親コンポーネントで定義されているonCheck関数とは以下です。onCheckChange関数は子コンポーネントに渡すときにonCheckという名前で渡されています。つまりonCheck関数 = onCheckChange関数ということ。
const onCheckChange = useCallback(
(checkedItem: Item) => {
// mapは配列の中の要素を1つずつ取り出し、処理後に新しい配列を作る
const newItems = items.map((item) => {
// checkされたinputにあるkeyと、stateにあるitemのkeyが一致するなら、doneのfalseをtrueにして、変更したitemをsetItemsに指定する
if (item.key === checkedItem.key) {
item.done = !item.done;
}
return item;
});
setItems(newItems);
},
[items, setItems]
);
一部以下のようにコメントアウトして一部ESLintを無効化しているのはlabel要素とinput要素を紐づけていないとエラーになるためです。今回は無効化しておきます。
{/* eslint-disable jsx-a11y/label-has-associated-control */}
TodoDones.tsxのコード
TODOリストにチェックマークが入って、さらに「完了済を削除」ボタンを押すことで、【完了済 一覧】エリアに移動します。この【完了済 一覧】エリアをTodoDones.tsxが管理しています。
親コンポーネントからは1つのstateを受け取っています。
- itemsDone
import { memo, VFC } from 'react';
import classes from './TodoDones.module.scss';
import { Item } from '../types/item';
type Props = {
itemsDone: Array<Item>;
};
export const TodoDones: VFC<Props> = memo((props) => {
const { itemsDone } = props;
return (
<div className={classes.complete}>
<div className={classes.todoDoneHeading}>完了済 一覧</div>
<ul className={classes.list}>
{itemsDone.map((itemDone) => (
<li key={itemDone.key} className={classes.item}>
<span className={classes.done}>完了済</span>
{itemDone.text}
</li>
))}
</ul>
</div>
);
});
18〜23行目で受け取ったitemsDoneの配列をループして1つ1つ表示させています。
おわりに
今回はVScodeでReactとTypeScriptを使ってTODOリストの作り方について説明しました。1ページだけのシンプルなアプリであれば、stateの管理やディレクトリ構成についてそこまで深く考えなくて作れてしまいますが、複雑なアプリになるほど整理しておく必要があります。
特にstateの管理はパフォーマンスに大きく影響するのでRecoilやReduxを使って1箇所で管理するようにしましょう。