初めに
副業に向けて React を学び、ポートフォリオ用に 案件管理ツール(CaseManagementTool) を Vite + TypeScript で開発しています。
備忘録として記事を残していきます。
今回は①案件一覧と⑤検索機能に向けて、検索部・一覧部のコンポーネント分割、props、親子間のデータの流れまでを実装しながら整理したメモです。
また、cursorで記事を書けるかの実験も兼ねています。
スタック: React 19 / TypeScript / Vite
読者想定: React 初級〜中級
この記事でやること
| 項目 | 内容 |
|---|---|
| 画面構成 | 検索部(Search)+ データ表示部(DataTable) |
| 型 |
Types.ts に Data / Search / props 用の型 |
| ロジック |
utils/DataFilter.ts で絞り込み |
| データの流れ | App → Search(ボタン)→ App → DataTable |
プロジェクトの全体像
最終的な機能(目標)
- 案件一覧(案件名・顧客名・ステータス・金額・納期・担当者)← 今回
- 案件追加
- 編集
- 削除
- 検索 ← 今回
ディレクトリ構成(現時点)
src/
App.tsx
types/Types.ts
components/
Search.tsx
DataTable.tsx
utils/
DataFilter.ts
Index.ts
コンポーネントは2つに分ける
考えていたこと:
Reactは初心者であり、以前に簡単なツールを作った程度で正直何もわかっていない状態です。
前回のツール作成時にコンポーネントという単語を知り、今回から意識することに。
まだ、コンポーネントがどんなものなのか。どれくらいの数に分けるのが一般的なのかよくわかっていないのでAIと相談しながら進めていきます。
結論: 検索 UI と一覧表示は 別コンポーネント に分けてよい。
state(案件一覧・検索条件・表示用データ)は 親の App に置く。
App(state を持つ)
├── Search(入力 + 検索ボタン)
└── DataTable(表で表示)
理由
- 検索欄だけ・表だけを差し替えやすい
- 編集・削除を足すときにフォームコンポーネントを追加しやすい
- 記事・ポートフォリオとして説明しやすい
今回作成するものは画面の要素分けがわかりやすかったため、想像通りの構成でした。
コンポーネントを分けたら「関数定義」まで書く?
考えていたこと:
コンポーネントについては「あー、なんかHTMLをブロックごとに分けるんだな~」くらいの理解で次の作業に。
ぶっちゃけ、分けてみたはいいもののいまいち何を書くかわかっていませんでした。
App.tsxみたいにHTMLをreturnするのは想像できていたのですが、その中で呼び出す想定のメソッド定義などはどうするんだろというのが次の疑問。
システム構成はしっかり考えていきたいところ。
実は新しいツールを作る前に以前のツールを拡張していく案も実はありましたが、
1ファイルにいろいろ詰め込まれすぎて読むの嫌になったし、コンポーネントやメソッド定義を分けるには遅すぎて萎えました。
これからReactを触る方は同じ過ちを犯さないようにしましょう。
コンポーネントも分けず、すべてのメソッドをAPP.tsxに記載するとメンテナンス性が終わります、まじで
AIの解答
はい。 ファイルを分ける = その部品用の関数コンポーネントを1ファイルに書く のが一般的です。
import type { DataTableProps } from "../types/Types";
export function DataTable({ Data }: DataTableProps) {
return (
<table>
<thead>
<tr>
<th>案件名</th>
<th>顧客名</th>
</tr>
</thead>
<tbody>
{Data.map((Item) => (
<tr key={Item.CaseName}>
<td>{Item.CaseName}</td>
<td>{Item.CustomerName}</td>
</tr>
))}
</tbody>
</table>
);
}
filter のような JSX を返さない処理 は utils/DataFilter.ts に分離します。
(ん~、聞き方が悪かったかなと思いつつ自分で調べてメソッド定義用のファイルを別に切っていくことにします。)
props 入門
考えていたこと:
コンポーネント分け、メソッド定義が終われば値の受け渡しです。
イメージしていた流れは
APP→コンポーネント→メソッド→コンポーネント→APP
コンポーネント→メソッド:引数での受け渡し
メソッド→コンポーネント:returnでの受け渡し
ここまではわかるのですが、
APP→コンポーネント
コンポーネント→APP
ここがよくわからないので解説してもらいました。
props とは
親から子へ渡す引数 です。子は基本的に 読むだけ(親の state を子で直接書き換えない)。
定義(TypeScript)
export type Data = {
CaseName: string;
CustomerName: string;
Details: string;
Amount: number;
Status: string;
Deadline: Date;
};
export type Search = {
SearchCaseName: string;
SearchCustomerName: string;
};
export type SearchProps = {
SearchItems: Search;
Data: Data[];
SetSearchItems: (Next: Search) => void;
SetData: (Next: Data[]) => void;
};
export type DataTableProps = {
Data: Data[];
};
子:受け取る
export function Search({
SearchItems,
Data,
SetSearchItems,
SetData,
}: Types.SearchProps) {
// ...
}
親:渡す
<Search
SearchItems={SearchItems}
Data={AllData}
SetSearchItems={SetSearchItems}
SetData={SetData}
/>
<DataTable Data={Data} />
| 記法 | 意味 |
|---|---|
Data={AllData} |
変数 AllData の 値 を渡す |
Data="AllData" |
文字列 "AllData" を渡す(意図と違いやすい) |
prop 名と変数名は一致しなくてよい。 例: Data={AllData} のように、意味が分かる名前で渡してよい。
皆さんはこれでわかりましたかね?
自分は理解できなさ過ぎてキレてました。
結局自分で調べたり、AIに追加で聞いてみたりした結果としては
- コンポーネントにはオブジェクトを1つ引数で渡せる
- 渡したいものをすべて詰め込める型を定義して、詰め込む
- コンポーネント→APPはAPPで定義したuseStateのset関数を
オブジェクトに詰めることで実現する
という理解になりました。
もし間違えていたら教えてください。
ここまでが特有のもので苦戦したところになります。
これ以降は単純なミスだったり、ど忘れしてた文法について生成AIが出力したものをそのまま記載します。
つまずき1: search と Search(PascalCase)
結論: JSX では 小文字始まりは HTML タグ、大文字始まりは自作コンポーネント です。
| 書き方 | 結果 |
|---|---|
<search /> |
HTML 扱い → Search.tsx の関数は呼ばれない |
<Search /> |
import したコンポーネントが動く |
import { search } from "./components/Search";
<search SearchItems={SearchItems} />
import { Search } from "./components/Search";
<Search SearchItems={SearchItems} />
関数定義を PascalCase にしても、JSX が小文字のままだと直らない。 定義・import・JSX の3つを揃えます。
つまずき2: モックデータの作り方
const TestData: Types.Data = Types.Data;
TestData.CaseName = "test";
Types.Data は 型 であり 値ではない ので、右辺に書けません。
const TestData: Types.Data = {
CaseName: "test",
CustomerName: "テスト企業",
Details: "",
Amount: 0,
Status: "進行中",
Deadline: new Date(),
};
const [AllData, SetAllData] = useState<Types.Data[]>([TestData]);
const [Data, SetData] = useState<Types.Data[]>([TestData]);
- 左の
: Types.Data→ 型注釈(コンパイル時) - 右の
{ ... }→ 実行時の値
データの流れ(検索ボタン方式)
想定フロー
App
→ Search(検索ボタン押下で DataFilter)
→ SetData で App の表示用 state を更新
→ DataTable が更新された Data を表示
App.tsx
import { useState } from 'react'
import { DataTable } from "./components/DataTable";
import { Search } from "./components/Search";
import type * as Types from "./types/Types";
import './App.css'
function App() {
const TestData: Types.Data = {
CaseName: "test",
CustomerName: "テスト企業",
Details: "",
Amount: 0,
Status: "進行中",
Deadline: new Date(),
};
const [AllData, SetAllData] = useState<Types.Data[]>([TestData]);
const [Data, SetData] = useState<Types.Data[]>([TestData]);
const [SearchItems, SetSearchItems] = useState<Types.Search>({
SearchCaseName: "",
SearchCustomerName: "",
});
return (
<>
<Search
SearchItems={SearchItems}
Data={AllData}
SetSearchItems={SetSearchItems}
SetData={SetData}
/>
<DataTable Data={Data} />
</>
)
}
export default App
-
AllData… フィルタの 元になる全件 -
Data… 表に表示するデータ
Search.tsx
import * as Utils from "../utils/Index";
import type * as Types from "../types/Types";
export function Search({ SearchItems, Data, SetSearchItems, SetData }: Types.SearchProps) {
const ChangeSearch = (SearchCaseName: string | null, SearchCustomerName: string | null) => {
const NewSearch = { ...SearchItems };
if (SearchCaseName != null) {
NewSearch.SearchCaseName = SearchCaseName;
}
if (SearchCustomerName != null) {
NewSearch.SearchCustomerName = SearchCustomerName;
}
SetSearchItems(NewSearch);
}
return (
<div>
<table>
<tr>
<th>案件名</th>
<th>顧客名</th>
</tr>
<tr>
<td><input type="text" value={SearchItems.SearchCaseName} onChange={(E) => ChangeSearch(E.target.value, null)} /></td>
<td><input type="text" value={SearchItems.SearchCustomerName} onChange={(E) => ChangeSearch(null, E.target.value)} /></td>
</tr>
</table>
<button type="button" onClick={() => SetData(Utils.DataFilter(Data, SearchItems))}>Search</button>
</div>
);
}
{ ...SearchItems } で 新しいオブジェクト を作ってから SetSearchItems すると、再レンダーが安定します。
utils/DataFilter.ts(現状)
import type * as Types from "../types/Types";
export function DataFilter(Data: Types.Data[], SearchItems: Types.Search) {
if(SearchItems.SearchCaseName == "" && SearchItems.SearchCustomerName == ""){
return Data
}else{
return Data.filter((Item) =>
Item.CaseName == SearchItems.SearchCaseName ||
Item.CustomerName == SearchItems.SearchCustomerName);
}
}
つまずき3: 検索が効かない・おかしい
原因A: includes("") と OR(初期案でハマった例)
Item.CaseName.includes(SearchItems.SearchCaseName) ||
Item.CustomerName.includes(SearchItems.SearchCustomerName);
空文字は "何か".includes("") === true なので、片方の入力が空だと OR で全件ヒット しやすい。
改善案(部分一致 + 空欄無視 + AND):
export function DataFilter(Data: Types.Data[], SearchItems: Types.Search) {
return Data.filter((Item) => {
const MatchCase =
SearchItems.SearchCaseName === "" ||
Item.CaseName.includes(SearchItems.SearchCaseName);
const MatchCustomer =
SearchItems.SearchCustomerName === "" ||
Item.CustomerName.includes(SearchItems.SearchCustomerName);
return MatchCase && MatchCustomer;
});
}
原因B: 絞り結果で元データを上書き
SetData(DataFilter(Data, ...)) の Data が すでに絞られた配列 だと、2回目以降や全件復帰ができない。
対策: AllData(全件)と Data(表示用)を分け、フィルタは常に AllData にかける。
<Search Data={AllData} SetData={SetData} />
<DataTable Data={Data} />
学んだことまとめ
| テーマ | ポイント |
|---|---|
| 分割 | Search / DataTable、state は App |
| props | 型定義 → 子で受け取り → 親で prop={値}
|
| JSX | コンポーネントは <Search />(PascalCase) |
| 型と値 |
Types.Data は型、データは { ... }
|
| 検索 |
SetData で親に戻す流れは正しい。filter 条件と全件保持が肝 |
完成した機能
- 検索機能
- 案件データ画面表示
次にやること
- 案件追加機能
- 案件削除機能
参考リンク
副業・ポートフォリオ学習用。お題をもとに React を実装しながら整理したシリーズ第1回。