フロントエンドワークショップ React入門ハンズオン
Reactで仕訳入力画面を作っていきます
コードは基本的には簡単な方法を選択したものになってるはず…
一部

のクセみたいなのがあるかもですが、まぁその辺は良い感じにお願いします
カンニングに使ってください
コード内が Subject
ってなってますが、資料では Item
に変更してます
コード書く前にReactについておさらい
React Componentは関数の形で記述する
JSXという記法でHTMLやReact Componentを記述する
TypeScriptで記述する際はファイルは .tsx
拡張子にする
基本的にはPropsやStateが変わると再レンダリングが走る
状態は useState
を用いて扱う。その返り値が [getter, setter]
になっている。
use***
はフックと呼ばれる特別な関数
名前は use
から始める
関数のトップレベルで呼ぶ
if
や for
の中などで呼ばない
early returnする前に必ず呼ぶ
仕訳入力画面について
左側に「借方勘定科目」と「金額」の入力欄がある
右側に「貸方勘定科目」と「金額」の入力欄がある
それぞれの「科目」の項目はAPIからJSONで取得する
金額はユーザーが数字を入力する
左右それぞれの合計を最後の行に表示する
そのとき、左右が一致して無ければその旨を表示する
こういう感じになる予定という図
<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
に置いていく
各行のデータとそれの配列があれば良さそうということでざっくりこんな感じ
src/types/journal.tsexport type JournalRow = Readonly<{
debitItem: string,
debitValue: number
creditItem: string,
creditValue: number,
}>
export type JournalData = Readonly<JournalRow[]>
<Row>
のインターフェイスを作っていく
<Row>
は各行のデータを表示/更新する機能を備えている
Propsを考えてみる
JournalRow
を受け取る
親の持っている状態を自身の操作で更新する update
関数
ひとまず素朴に (newData) => void
くらいでどうか
src/Row.tsxtype 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部分から書いてみましょう
必要そうな要素を並べる
src/Row.tsx<div>
<select>{/* ここに課目が並ぶ */}</select>
<input type='number' />
<select>{/* ここに課目が並ぶ */}</select>
<input type='number' />
</div>
<Journal>
を作って試しに表示させてみる
見た目を作ったので一旦表示させてみたくないですか?ということで表示させるために <Journal>
を雑に作って <App>
に置いて実際にレンダリングされるようにする
src/Journal.tsximport { useState } from "react"
import { Row } from "./Row";
import { JournalData } from "./types/journal"
export const Journal = () => {
const [data, setData] = useState<JournalData>([]);
return <>{
data.map((d) => <Row data={d} update={() => {/* 一旦空の関数を渡してごまかす */}} />)
}</>
}
<>
は <React.Fragment>
の省略形
React Componentは JSX.Element[]
のような配列ではなく、1つのElementに囲われている形である必要があるが、 <div>
などで囲いたくない場合等に使用する
このままだと配列が空なので、適当に初期データを生成しておけるようにしておく
tsconst createInitialData = () => ({
debitItem: '',
debitValue: 0,
creditItem: '',
creditValue: 0,
});
diff- const [data, setData] = useState<JournalData>([]);
+ const [data, setData] = useState<JournalData>([createInitialData()]);
そして <App>
に <Journal>
をマウントさせたら、一度 npm start
してみましょう
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"
- href="https://reactjs.org"
- target="_blank"
- rel="noopener noreferrer"
- >
- Learn React
- </a>
- </header>
+ <Journal />
</div>
表示されましたか 🎉🎉🎉
<Journal>で行を追加出来るようにする
<button>
を設置して行を追加できるようにする
src/Journal.tsx.diff export const Journal = () => {
const [data, setData] = useState<JournalData>([createInitialData()]);
+ const addRow = () => {
+ setData((prev) => [...prev, createInitialData()])
+ }
+
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()
を使う
src/Journal.tsx.diff const createInitialData = () => ({
+ id: crypto.randomUUID(),
debitItem: '',
debitValue: 0,
creditItem: '',
このときTSの型エラーが出る
何故なら、tscの持っている型情報には crypto.randomUUID()
がまだ追加されていないので手元で足してあげる必要がある
*.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
を作成してテストを書いていく
src/Journal.test.tsximport { 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'
を与えておく
ここで npm run test
するとJestの実行時には crypto
がブラウザと違ってglobalに無いことを怒られるので、setupファイルを置いて回避する
src/setupTests.ts
を起動時に読んでくれる
テスト成功しましたか?
コラム: React Componentのテストとモック
今回の <Journal />
のテストは <Row />
などをそのままレンダリングしている
一方で子要素が増えるとテスト時に影響を受ける範囲が広がり、純粋に「行を追加する」という機能だけをテストし辛くなっていく
そのような場合はJestの機能を用いてmockするのも1つのアイデア
課目を取得する①
今回はAPIを叩いた気になれるように、JSONファイルを置いておいて、 fetch()
で取得してみましょう
課目を取得する②
<Row>
の中で fetch
をする
fetch
は非同期に通信をするAPI
Promise<Response>
が返ってくる
このような処理をする場合は副作用を扱う useEffect
を用いて、通信が完了したらその結果を用いて状態を更新して反映できるようにする
第2引数に配列で依存する値を渡すことでその値が更新されたら処理を実行できる
[]
を渡すとマウント時にのみ実行されるように出来る
第1引数の関数が関数を返すとき、その返ってきた関数が副作用を呼び出す前に呼ばれる
example.tsxuseEffect(() => {
subscribe(onUpdate);
return () => unsubscribe()
}, [onUpdate])
↑はアンマウント時に購読を取り消してくれる
src/Row.tsx.diff+type ItemsAPI = Readonly<string[]>
+
type RowProps = Readonly<{
data: JournalRow
update: (newData: JournalRow) => void
}>
export const Row: React.VFC<RowProps> = () => {
+ const [items, setItems] = useState<ItemsAPI | undefined>(undefined);
+
+ 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
があると嬉しそう
src/Journal.tsx.diff export const Journal = () => {
- const [data, setData] = useState<JournalData>([createInitialData()]);
-
- const addRow = () => {
- setData((prev) => [...prev, createInitialData()])
- }
+ 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
src/journal-data.tsimport { 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: () => {}
}
}
src/journal-data.test.tsimport { 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.data[2].id;
act(() => {
result.current.updateDataById(id, {debitValue: 100});
});
expect(result.current.data[2]).toEqual(
expect.objectContaining({debitValue: 100})
)
act(() => {
result.current.updateDataById(id, {debitItem: 'test',debitValue: 200});
})
expect(result.current.data[2]).toEqual(
expect.objectContaining({debitItem: 'test',debitValue: 200})
)
})
act
はstateの更新があるときに囲っておく
useJournalData
を実装する②
テストで一通り欲しい振る舞いは書かれているので、それを満たすように実装を書く
Try 書けますよね?
素朴に書くとこういう感じになると思う
src/journal-data.ts.diff+ const [data, setData] = useState<JournalData>([createInitialData()]);
+
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))
+ },
+ addRow: () => { setData((prev) => [...prev, createInitialData()]) }
}
}
<Row>
で使えるように更新用の関数を渡す
src/Journal.tsx.diff- const [data, setData] = useState<JournalData>([createInitialData()]);
-
- const addRow = () => {
- setData((prev) => [...prev, createInitialData()])
- }
+ 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
を受け取るようにする
src/Row.tsx.difftype 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 }) => {
tsonChange: (event: React.SyntheticEvent<HTMLSelectElement>)
という感じのインターフェイスで型を与えておくと良い感じに出来る
イベントに応じて、 React.MouseEvent
などが React.SyntheticEvent
を継承したものとして在る
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>
を実装する
借方と貸方それぞれの合計
不足額がある場合はどっちがいくら足りないか表示する
src/Sum.tsxtype 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
が増えるのでテストは壊れます
お疲れ様でした
ひとまず一通り動くようなものが出来たはず
いかがでしたか?

ここまで出来たら大丈夫です!ここに感想をどうぞ!!

続きは時間があったら読んでください。
useMemo
などの話は全員の前でやれたらやります
useMemo
と useCallback
について
値や関数をメモ化するための組み込みフック
useEffect
などで依存を書いた際の値の同一性を担保するためなどで活躍する
プリミティブな値は ===
、それ以外は Object.is
で比較される
useJournalData
が返す関数たちについて考える
updateDateById
や addRow
はレンダリングのたびに関数オブジェクトが生成されている
src/Journal.tsx.diff const {data, updateDataById, addRow} = useJournalData();
+ useEffect(() => {
+ console.log('addRow is updated')
+ }, [addRow]);
+
return <>{
これで行を追加してレンダリングを走らせると毎回 addRow
が違うオブジェクトになっているので、 useEffect
の中身が実行されていることが分かる
addRow
をメモ化すると解決する
src/journal-data.ts.diff+
+ const addRow = useCallback(() => {
+ setData((prev) => [...prev, createInitialData()])
+ }, []);
return {
data,
updateDataById: (id: string, newData: Partial<JournalRow>) => {
setData(prev => prev.map((d) => d.id === id ? { ...d, ...newData } : d))
},
- addRow: () => { setData((prev) => [...prev, createInitialData()]) }
+ addRow
}
メモ化とパフォーマンス
useMemo
はヘビーな計算結果をメモ化しておくことにも勿論使われる
特に独自フックなどで関数やオブジェクトや配列を返却する場合、その後に別のフックやComponentに渡されることを考えて、基本的には useMemo
や useCallback
しておくと安心
コラム: React Component自体の設計について
今回のハンズオンではかなり素朴にあらゆるComponentやフックを設計しました
React Componentは素朴な関数なので、関数合成を用いてクリーンアーキテクチャ的なアイデアを持ち込んで債務を分離するということも出来ます
JournalView
と useJournalPresenter
を作って合成したものを <Journal>
として提供する
PresenterはPropsを受け取って加工したりしてViewに渡す
use
から始めてフックとして振る舞うようにしておく
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ごとの状態にしかならないことに注意

どこもかしこも
data
って書いてるけど、もう少し命名なんとかならないか