フロントエンドワークショップ React入門ハンズオン
Reactで仕訳入力画面を作っていきます
コードは基本的には簡単な方法を選択したものになってるはず…
一部pastak.iconのクセみたいなのがあるかもですが、まぁその辺は良い感じにお願いします
カンニングに使ってください
コード内がSubjectってなってますが、資料ではItemに変更してます
コード書く前にReactについておさらい
React Componentは関数の形で記述する
JSXという記法でHTMLやReact Componentを記述する
TypeScriptで記述する際はファイルは .tsx拡張子にする
基本的にはPropsやStateが変わると再レンダリングが走る
状態は useStateを用いて扱う。その返り値が[getter, setter]になっている。
use***はフックと呼ばれる特別な関数
名前はuseから始める
関数のトップレベルで呼ぶ
ifやforの中などで呼ばない
early returnする前に必ず呼ぶ
仕訳入力画面について
左側に「借方勘定科目」と「金額」の入力欄がある
右側に「貸方勘定科目」と「金額」の入力欄がある
それぞれの「科目」の項目はAPIからJSONで取得する
金額はユーザーが数字を入力する
左右それぞれの合計を最後の行に表示する
そのとき、左右が一致して無ければその旨を表示する
こういう感じになる予定という図
https://gyazo.com/cdc4f9fb836c759e98f926fa9e77c3c6
<Journal />のstateとしてdata[]を持っておいて、それを更新したり、各Componentに配る形で素朴にアプリケーションの状態を管理する
Create React Appでの環境セットアップ
Create React App(以下、craと省略することがあります)はReactの開発環境を一発でバシッと作ってくれるツール
継続的に改善がされていて、その時々のベストプラクティス集的な感じにもなっている
npm init react-app -- . --template typescript
npm start で localhost:3000で起動
自動でリロードとかもしてくれて便利
npm run testでテスト実行
何が中で動いてるかとかの詳細は次のパートでやるので、ここでは一旦便利に全部動くぞという世界観で進めていきます
自動リロードの様子を眺める
npm startでサーバーとブラウザが立ち上がる
src/App.tsxを編集するとブラウザが自動で再読み込みされて変更が適応されていることを確認
便利
まず全体で利用することになる型を書いておく
src/types/journal.tsに置いていく
各行のデータとそれの配列があれば良さそうということでざっくりこんな感じ
code: src/types/journal.ts
export type JournalRow = Readonly<{
debitItem: string,
debitValue: number
creditItem: string,
creditValue: number,
}>
export type JournalData = Readonly<JournalRow[]>
<Row>のインターフェイスを作っていく
<Row>は各行のデータを表示/更新する機能を備えている
Propsを考えてみる
JournalRowを受け取る
親の持っている状態を自身の操作で更新するupdate関数
ひとまず素朴に (newData) => voidくらいでどうか
code: src/Row.tsx
type RowProps = Readonly<{
data: JournalRow
update: (newData: JournalRow) => void
}>
export const Row: React.VFC<RowProps> = () => null; //一旦中身無いので、nullを返しておく
React Componentで何もレンダリングしない時は nullを明示的に返す
React.FCにはchildrenが最初から定義されているが、これが原因でchildrenが本当に必要かどうかが型から分からない問題を解決するために React.VFCというchildrenの無い型があるので、基本はこれを使っていく。
将来的にはReact.FCからもchildrenが削除される予定なので、その際にはVFCをFCに置換できる予定
コラム: Function Componentの書き方・宣言方法について
export function Row {}という書き方も出来たり、React.VFCは無くても良いという説もあります。実際、craでも#8177でReact.FCが削除されています。(src/App.tsxを見るとfunction App()で定義され、export default Appとdefault exportされていることが分かります) ですが、今回は明示的に型を書いていく方法を取っていきます。このハンズオンでは型を書くことに慣れたり、それによりコードを書いていく手助けになるという考えからです。
<Row>のJSXを書く
まずは素朴にJSX部分から書いてみましょう
必要そうな要素を並べる
code: src/Row.tsx
<div>
<select>{/* ここに課目が並ぶ */}</select>
<input type='number' />
<select>{/* ここに課目が並ぶ */}</select>
<input type='number' />
</div>
<Journal>を作って試しに表示させてみる
見た目を作ったので一旦表示させてみたくないですか?ということで表示させるために<Journal>を雑に作って<App>に置いて実際にレンダリングされるようにする
code: src/Journal.tsx
import { useState } from "react"
import { Row } from "./Row";
import { JournalData } from "./types/journal"
export const Journal = () => {
return <>{
data.map((d) => <Row data={d} update={() => {/* 一旦空の関数を渡してごまかす */}} />)
}</>
}
<>は<React.Fragment>の省略形
React ComponentはJSX.Element[]のような配列ではなく、1つのElementに囲われている形である必要があるが、<div>などで囲いたくない場合等に使用する
このままだと配列が空なので、適当に初期データを生成しておけるようにしておく
code: ts
const createInitialData = () => ({
debitItem: '',
debitValue: 0,
creditItem: '',
creditValue: 0,
});
code: diff
そして<App>に<Journal>をマウントさせたら、一度 npm startしてみましょう
code: src/App.tsx.diff
<div className="App">
- <header className="App-header">
- <img src={logo} className="App-logo" alt="logo" />
- <p>
- Edit <code>src/App.tsx</code> and save to reload.
- </p>
- <a
- className="App-link"
- target="_blank"
- rel="noopener noreferrer"
- >
- Learn React
- </a>
- </header>
+ <Journal />
</div>
表示されましたか 🎉🎉🎉
<Journal>で行を追加出来るようにする
<button>を設置して行を追加できるようにする
code: src/Journal.tsx.diff
export const Journal = () => {
+ const addRow = () => {
+ }
+
return <>{
data.map((d) => <Row data={d} update={() => {/* 一旦空の関数を渡してごまかす */}} />)
- }</>
+ }
+ <button onClick={addRow}>行を追加</button>
+ </>
}
setStateのsetter(ここでは setData)は、更新用の値を渡すことも出来るし、関数を渡すことで現在の値から次の状態を生成することも出来る
ここで一旦Devtoolのconsoleを見てみてください
Warning: Each child in a list should have a unique "key" prop.
mapを使って要素をリストするときには keyを使ってユニークであることを表現する必要がある
keyを使うことでリスト内の要素の順番などが変更された場合にも要素がどのように対応しているかが検証され、更新される範囲を最小限に出来る
このとき配列のindexをkeyに使うと酷い目にあうケースがあるので注意
というわけで今回はuuidを与えておきます
JournalDataにidを追加する
JournalRowに id: stringを追加する
uuidの生成にはCrypto.randomUUID()を使う
code: src/Journal.tsx.diff
const createInitialData = () => ({
+ id: crypto.randomUUID(),
debitItem: '',
debitValue: 0,
creditItem: '',
このときTSの型エラーが出る
何故なら、tscの持っている型情報にはcrypto.randomUUID()がまだ追加されていないので手元で足してあげる必要がある
code: src/types/global.d.ts
declare interface Crypto {
randomUUID: () => string;
}
*.d.tsは型定義宣言が書いてあるファイル
VSCodeをリロードすると読み込まれるはず
declareで型推論器にだけ情報を渡す
コラム: ファイルの置き場について src/types以下に置いてますが、アプリケーション向けの型などの実装と混ざって微妙な気持ちになるので、 types/などに置いておいて、tsconfig.jsonを編集する方が素直で良いかも(今回は変更を最小限にするためにここに置いてます)
idを使ってkeyを設定する
<Row>に値を渡すところで key={d.id}も指定する
JournalRowの型にidを足しておくこと
Warningが消えていることを確認する
行の追加をテストする
src/App.test.tsxを見ると雰囲気掴める?
一方で、Appの中身は変更していて今は用をなさないので削除しておく
というわけで、ボタンを押したら行が追加される様子をテストしてみましょう
src/Journal.test.tsxを作成してテストを書いていく
code: src/Journal.test.tsx
import { screen, render, fireEvent } from "@testing-library/react";
import {Journal} from "./Journal";
test('「行を追加」ボタンをクリックすると1行増える', () => {
render(<Journal />);
const button = screen.getByText('行を追加');
fireEvent.click(button);
expect(screen.getAllByRole('row').length).toBe(2);
})
renderでComponentをレンダリングして、ボタンを見つけてクリックする
その後にrole=rowが2つになっていることを確認してテスト成功
craに入っているeslintに従うとgetByRoleなどを使うように誘導されるので、行を発見できるように <Row>の<div>にrole='row'を与えておく
code: src/Row.tsx.diff
- <div>
+ <div role="row">
<select>{/* ここに課目が並ぶ */}</select>
ここで npm run test するとJestの実行時にはcryptoがブラウザと違ってglobalに無いことを怒られるので、setupファイルを置いて回避する
src/setupTests.tsを起動時に読んでくれる
code: src/setupTests.ts
global.crypto = require('crypto');
テスト成功しましたか?
コラム: React Componentのテストとモック
今回の<Journal />のテストは<Row />などをそのままレンダリングしている
一方で子要素が増えるとテスト時に影響を受ける範囲が広がり、純粋に「行を追加する」という機能だけをテストし辛くなっていく
そのような場合はJestの機能を用いてmockするのも1つのアイデア
code: src/Journal.test.tsx
jest.mock("./Row", () => ({
Row: () => <div role="row"></div>
}));
課目を取得する①
今回はAPIを叩いた気になれるように、JSONファイルを置いておいて、fetch()で取得してみましょう
code: public/api/items.json
["現金",
"当座預金",
"普通預金",
"定期預金",
"その他の預金",
"受取手形",
"売掛金",
"有価証券",
"商品"]
課目を取得する②
<Row>の中でfetchをする
fetchは非同期に通信をするAPI
Promise<Response>が返ってくる
このような処理をする場合は副作用を扱うuseEffectを用いて、通信が完了したらその結果を用いて状態を更新して反映できるようにする
第2引数に配列で依存する値を渡すことでその値が更新されたら処理を実行できる
[]を渡すとマウント時にのみ実行されるように出来る
第1引数の関数が関数を返すとき、その返ってきた関数が副作用を呼び出す前に呼ばれる
code: example.tsx
useEffect(() => {
subscribe(onUpdate);
return () => unsubscribe()
↑はアンマウント時に購読を取り消してくれる
code: src/Row.tsx.diff
+type ItemsAPI = Readonly<string[]>
+
type RowProps = Readonly<{
data: JournalRow
update: (newData: JournalRow) => void
}>
export const Row: React.VFC<RowProps> = () => {
+
+ useEffect(() => {
+ fetch('/api/items.json')
+ .then((res) => res.json())
+ .then((json: ItemsAPI) => {
+ setItems(json);
+ });
+ }, []);
return <div role="row">
- <select>{/* ここに課目が並ぶ */}</select>
+ <select>{
+ items?.map(item => <option value={item}>{item}</option>)
+ }</select>
<input type='number' />
- <select>{/* ここに課目が並ぶ */}</select>
+ <select>{
+ items?.map(item => <option value={item}>{item}</option>)
+ }</select>
<input type='number' />
</div>
}
更新を反映できるようにする
現在の値とJournalData[]の特定の要素を更新できる関数を返してくれるフックを作る
こういう感じになってくれるuseJournalDataがあると嬉しそう
code: src/Journal.tsx.diff
export const Journal = () => {
-
- const addRow = () => {
- }
+ const {data, updateDataById, addRow} = useJournalData();
return <>{
- data.map((d) => <Row key={d.id} data={d} update={() => {/* 一旦空の関数を渡してごまかす */}} />)
+ data.map((d) => <Row key={d.id} data={d} update={updateDataById} />)
}
<button onClick={addRow}>行を追加</button>
</>
useJournalDataを実装する①
欲しい形も決まってるので、先にテストを書く作戦で向き合ってみる
npm i -D @testing-library/react-hooks
code: src/journal-data.ts
import { JournalData, JournalRow } from "./types/journal"
export const useJournalData = (): Readonly<{
data: JournalData,
updateDataById: (id: string, newData: Partial<JournalRow>) => void;
addRow: () => void;
}> => {
return {
data: [],
updateDataById: (id: string, newData: Partial<JournalRow>) => {},
addRow: () => {}
}
}
code: src/journal-data.test.ts
import { renderHook } from '@testing-library/react-hooks'
import { act } from 'react-dom/test-utils';
import { useJournalData } from './journal-data';
test('updateDateByIdで特定のデータを更新できる', () => {
const {result} = renderHook(useJournalData);
act(() => {
result.current.addRow();
result.current.addRow();
result.current.addRow();
result.current.addRow();
})
const id = result.current.data2.id; act(() => {
result.current.updateDataById(id, {debitValue: 100});
});
expect(result.current.data2).toEqual( expect.objectContaining({debitValue: 100})
)
act(() => {
result.current.updateDataById(id, {debitItem: 'test',debitValue: 200});
})
expect(result.current.data2).toEqual( expect.objectContaining({debitItem: 'test',debitValue: 200})
)
})
actはstateの更新があるときに囲っておく
useJournalDataを実装する②
テストで一通り欲しい振る舞いは書かれているので、それを満たすように実装を書く
Try 書けますよね?
素朴に書くとこういう感じになると思う
code: src/journal-data.ts.diff
+
return {
- data: [],
- updateDataById: (id: string, newData: Partial<JournalRow>) => {},
- addRow: () => {}
+ data,
+ updateDataById: (id: string, newData: Partial<JournalRow>) => {
+ setData(prev => prev.map((d) => d.id === id ? { ...d, ...newData } : d))
+ },
}
}
<Row>で使えるように更新用の関数を渡す
code: src/Journal.tsx.diff
-
- const addRow = () => {
- }
+ const {data, updateDataById, addRow} = useJournalData();
return <>{
- data.map((d) => <Row key={d.id} data={d} update={() => {/* 一旦空の関数を渡してごまかす */}} />)
+ data.map((d) => {
+ const update = (_data: Partial<JournalRow>) => updateDataById(d.id, _data)
+ return <Row key={d.id} data={d} update={update} />
+ })
}
<button onClick={addRow}>行を追加</button>
<Row>のそれぞれのonChangeで渡ってきた更新用の関数を叩くようにする
updateを受け取るようにする
code: src/Row.tsx.diff
type RowProps = Readonly<{
data: JournalRow
- update: (newData: JournalRow) => void
+ update: (newData: Partial<JournalRow>) => void
}>
- export const Row: React.VFC<RowProps> = ({ }) => {
+ export const Row: React.VFC<RowProps> = ({ update }) => {
code: ts
onChange: (event: React.SyntheticEvent<HTMLSelectElement>)
という感じのインターフェイスで型を与えておくと良い感じに出来る
イベントに応じて、React.MouseEventなどが React.SyntheticEventを継承したものとして在る
code: src/Row.tsx.diff
return <div role="row">
- <select>{
+ <select onChange={(e: React.SyntheticEvent<HTMLSelectElement>) => update({
+ debitItem: e.currentTarget.value
+ })}>{
items?.map(item => <option value={item}>{item}</option>)
}</select>
- <input type='number' />
- <select>{
+ <input type='number' onChange={(e: React.SyntheticEvent<HTMLInputElement>) => update({
+ debitValue: e.currentTarget.valueAsNumber
+ })}/>
+ <select onChange={(e: React.SyntheticEvent<HTMLSelectElement>) => update({
+ creditItem: e.currentTarget.value
+ })}>{
items?.map(item => <option value={item}>{item}</option>)
}</select>
- <input type='number' />
+ <input type='number' onChange={(e: React.SyntheticEvent<HTMLInputElement>) => update({
+ creditValue: e.currentTarget.valueAsNumber
+ })}/>
</div>
HTMLInputElement.valueAsNumberは値を数値として取得できるので型変換をしなくて済むので便利
ここでdataを受け取っていたが使ってなかったことに気付くので、それぞれ初期値としてdefaultValueで渡しておく
inputの初期値に0が入るはず
合計を計算する
<Sum>を実装する
借方と貸方それぞれの合計
不足額がある場合はどっちがいくら足りないか表示する
code: src/Sum.tsx
type SumProps = Readonly<{
data: JournalData
}>
export const Sum: React.VFC<SumProps> = ({data}) => {
const debit = data.reduce((sum, d) => sum + d.debitValue, 0);
const credit = data.reduce((sum, d) => sum + d.creditValue, 0);
return <div role="row">
借方の合計 {debit}円<br/>
貸方の合計 {credit}円<br/>
{
debit !== credit ?
<span style={{color: 'red'}}>
{debit > credit ? "貸方" : "借方"}の不足額: {Math.abs(debit - credit)}円
</span>
: null
}
</div>
}
JSX内でJSを実行する場合は{}で囲む
{}の中は式しか書けないので、条件分岐させたい場合は三項演算子を入れ子させる
ここでrole=rowが増えるのでテストは壊れます
お疲れ様でした
ひとまず一通り動くようなものが出来たはず
いかがでしたか?
pastak.icon ここまで出来たら大丈夫です!ここに感想をどうぞ!!
pastak.icon続きは時間があったら読んでください。useMemoなどの話は全員の前でやれたらやります
useMemoとuseCallbackについて
値や関数をメモ化するための組み込みフック
useEffectなどで依存を書いた際の値の同一性を担保するためなどで活躍する
プリミティブな値は===、それ以外はObject.isで比較される
useJournalDataが返す関数たちについて考える
updateDateByIdやaddRowはレンダリングのたびに関数オブジェクトが生成されている
code: src/Journal.tsx.diff
const {data, updateDataById, addRow} = useJournalData();
+ useEffect(() => {
+ console.log('addRow is updated')
+
return <>{
これで行を追加してレンダリングを走らせると毎回addRowが違うオブジェクトになっているので、useEffectの中身が実行されていることが分かる
addRowをメモ化すると解決する
code: src/journal-data.ts.diff
+
+ const addRow = useCallback(() => {
+ }, []);
return {
data,
updateDataById: (id: string, newData: Partial<JournalRow>) => {
setData(prev => prev.map((d) => d.id === id ? { ...d, ...newData } : d))
},
+ addRow
}
メモ化とパフォーマンス
useMemoはヘビーな計算結果をメモ化しておくことにも勿論使われる
特に独自フックなどで関数やオブジェクトや配列を返却する場合、その後に別のフックやComponentに渡されることを考えて、基本的にはuseMemoやuseCallbackしておくと安心
コラム: React Component自体の設計について
今回のハンズオンではかなり素朴にあらゆるComponentやフックを設計しました
React Componentは素朴な関数なので、関数合成を用いてクリーンアーキテクチャ的なアイデアを持ち込んで債務を分離するということも出来ます
JournalViewとuseJournalPresenterを作って合成したものを<Journal>として提供する
PresenterはPropsを受け取って加工したりしてViewに渡す
useから始めてフックとして振る舞うようにしておく
code: connect.tsx
// 実際にはもう少し色々やることになると思うので、イメージ図みたいな感じです
function connect(usePresenter, View) {
const Component: React.FC = (props) => {
const propsForView = usePresenter(props);
return <View {...props} />
};
return Component;
}
Container ComponentとPresentational Componentに分けて実装する
Try時間が余ったらやってみて欲しいことの例
こういうことが出来るとより良くなりそうという変更の例です
DevToolのconsoleを見ると、inputが空になったときにNaNになってしまうという問題があるので修正する
<Row>もテストを書く
行を削除できるようにする
素朴に要素が並んでいるが、<table>などを使って整形してみる
role="table"やrole="cell"とかをちゃんと付けるとかでも良いですね
巨大な状態を1つ親が持っていて、それを配る形になっているが、もう少し良い設計を採用してみる
useJournalDataがあらゆる関数も返してくるが、なんかもう少し気が利いたり出来そう
inputの値などをバリデーションする
useRefを使ってDOMへの参照を持って値を取得する
科目名を貸方と借方で分離させる
useItemsでのAPIからのJSON取得、今は<Row />がマウントされるたびに取得しているが、フロントエンド側でキャッシュをすることが出来そう
フックに切り出して、共通化。その上でなんらかのキャッシュを入れる
適当な変数に入れておく、useSWRを使うなど
useStateに入れるだけでは、その呼び出し元のComponentごとの状態にしかならないことに注意
pastak.icon どこもかしこも dataって書いてるけど、もう少し命名なんとかならないか