はじめに
管理画面とかで親子関係のあるセレクトボックスを実装しなくてはいけないこと、ありますよね?
以前、以下の記事でも紹介したものと同じ見た目にはなりますが、今回はセレクトボックスのデータをAPIから取得する形で実装してみます。
実物はこちら
リポジトリはこちら
概要
セレクトボックスのUIには特に変更はありませんが、選択した値が確認できるようテスト用のサブミットボタンを追加しています。
このボタンを押すと、選択された値をイベントから取得して alert
で表示します。
前回は、セレクトボックスの表示のために必要なデータを最初から全てフロント側で持っていましたが、今回は何も持っていません。
以下のケースを想定して作成しています。
- 必要なデータはAPIから取得する
- データはDBに格納されている(今回は一つの配列をDBと見立てています)
- 中項目・小項目には、一つ上の項目と紐づけるための
parentId
がある - 大項目の
parentId
はnull
実装
早速、データを見てみましょう。
export const parentChildSelectItems: Array<ParentChildSelectItem> = [
{
id: 1,
parentId: null,
value: 'item-1'
},
{
id: 2,
parentId: null,
value: 'item-2'
},
// 中項目
{
id: 3,
parentId: 1,
value: 'item-1-1'
},
{
id: 4,
parentId: 1,
value: 'item-1-2'
},
{
id: 5,
parentId: 2,
value: 'item-2-1'
},
{
id: 6,
parentId: 2,
value: 'item-2-2'
},
// 小項目
{
id: 7,
parentId: 3,
value: 'item-1-1-1'
},
{
id: 8,
parentId: 3,
value: 'item-1-1-2'
},
{
id: 9,
parentId: 4,
value: 'item-1-2-1'
},
{
id: 10,
parentId: 4,
value: 'item-1-2-2'
},
{
id: 11,
parentId: 5,
value: 'item-2-1-1'
},
{
id: 12,
parentId: 5,
value: 'item-2-1-2'
},
{
id: 13,
parentId: 6,
value: 'item-2-2-1'
},
{
id: 14,
parentId: 6,
value: 'item-2-2-2'
},
]
大・中・小、全ての項目が一つの配列(DB)に格納されています。
以下が完成したコードです。
'use client'
import {Box, Button} from "@mui/material";
import {ChangeEvent, FormEvent, useEffect, useState} from "react";
import {parentChildSelectItems} from "@/data/data";
import {FetchItemsRequestType, ParentChildSelectItem} from "@/types/parent-child-select"
const isUseMockData = true
const fetchItems = async (param: FetchItemsRequestType): Promise<Array<ParentChildSelectItem>> => {
if (isUseMockData) {
return parentChildSelectItems.filter(item => item.parentId === param.parentId)
} else {
const response = await fetch('/api/parent-child-select/items', {
method: 'POST',
body: JSON.stringify(param)
})
const {items} = await response.json()
return items
}
}
export default function ParentChildSelect() {
const [items, setItems] = useState<Array<ParentChildSelectItem>>([])
const [subItems, setSubItems] = useState<Array<ParentChildSelectItem>>([])
const [subSubItems, setSubSubItems] = useState<Array<ParentChildSelectItem>>([])
useEffect(() => {
(async () => {
const newItems = await fetchItems({ parentId: null })
setItems(newItems)
})()
}, [])
const handleChangeItem = async (event: ChangeEvent<HTMLSelectElement>) => {
const newSubItem = await fetchItems({ parentId: parseInt(event.target.value) })
setSubItems(newSubItem)
setSubSubItems([])
}
const handleChangeSubItem = async (event: ChangeEvent<HTMLSelectElement>) => {
const newSubSubItem = await fetchItems({ parentId: parseInt(event.target.value) })
setSubSubItems(newSubSubItem)
}
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const item = form.get("item") ? items.find(item => item.id === parseInt(form.get("item") as string))?.value : "";
const subItem = form.get("subItem") ? subItems.find(subItem => subItem.id === parseInt(form.get("subItem") as string))?.value : "";
const subSubItem = form.get("subSubItem") ? subSubItems.find(subSubItem => subSubItem.id === parseInt(form.get("subSubItem") as string))?.value : "";
alert(
`item: ${item}\nsubItem: ${subItem}\nsubSubItem: ${subSubItem}`
);
}
return (
<main>
<Box mt={2} ml={2}>
<form onSubmit={onSubmit}>
<Button type="submit" variant="contained">submit</Button>
<Box mt={2}>
<Box>
大項目
</Box>
<Box>
<select className="w-40" onChange={handleChangeItem} name="item">
<option value=""></option>
{
items.map(item => (
<option key={item.id} value={item.id}>{item.value}</option>
))
}
</select>
</Box>
</Box>
<Box mt={2}>
<Box>
中項目
</Box>
<Box>
<select className="w-40" onChange={handleChangeSubItem} name="subItem">
<option value=""></option>
{
subItems.map(subItem => (
<option key={subItem.id} value={subItem.id}>{subItem.value}</option>
))
}
</select>
</Box>
</Box>
<Box mt={2}>
<Box>
小項目
</Box>
<Box>
<select className="w-40" name="subSubItem">
<option value=""></option>
{
subSubItems.map(subSubItem => (
<option key={subSubItem.id} value={subSubItem.id}>{subSubItem.value}</option>
))
}
</select>
</Box>
</Box>
</form>
</Box>
</main>
);
}
各部分の解説
APIの呼び出し処理はカスタムフックの useFetchItems
になります。
const fetchItems = async (param: FetchItemsRequestType): Promise<Array<ParentChildSelectItem>> => {
if (isUseMockData) {
return parentChildSelectItems.filter(item => item.parentId === param.parentId)
} else {
const response = await fetch('/api/parent-child-select/items', {
method: 'POST',
body: JSON.stringify(param)
})
const {items} = await response.json()
return items
}
}
呼んでいるAPIはというと、Next.js の API Routes で実装しています。
import { NextRequest, NextResponse } from "next/server"
import {parentChildSelectItems} from "@/data/data";
import {ParentChildSelectItem} from "@/types/parent-child-select";
export async function POST(request: NextRequest): Promise<NextResponse> {
const params = await request.json()
const filteredItems: ParentChildSelectItem[] = parentChildSelectItems.filter(item => item.parentId === params.parentId)
return NextResponse.json({
items: filteredItems
})
}
Reactレシピシリーズは GitHub Pages でホスティングしているので、公開サイトではこのAPIは使えません。
なので、モックデータから取得するようにしています。
const fetchItems = async (param: FetchItemsRequestType): Promise<Array<ParentChildSelectItem>> => {
if (isUseMockData) {
return parentChildSelectItems.filter(item => item.parentId === param.parentId)
} else {
大項目は、常に同じなので画面描画時に取得します。
useEffect(() => {
(async () => {
const newItems = await fetchItems({ parentId: null })
setItems(newItems)
})()
}, [])
中項目・小項目は、上位項目が選択されたタイミングで取得します。
大項目が変更された場合、中項目の中身が決まりますが、この時点では小項目の中身は決まりません。
なので、空配列で更新しているのがポイントです。
const handleChangeItem = async (event: ChangeEvent<HTMLSelectElement>) => {
const newSubItem = await fetchItems({ parentId: parseInt(event.target.value) })
setSubItems(newSubItem)
setSubSubItems([])
}
const handleChangeSubItem = async (event: ChangeEvent<HTMLSelectElement>) => {
const newSubSubItem = await fetchItems({ parentId: parseInt(event.target.value) })
setSubSubItems(newSubSubItem)
}
おわりに
さて、いかがだったでしょうか?
同じUIでも、データの取り方や持ち方で実装が異なることが伝わりましたでしょうか。
Reactレシピシリーズでは、「こんなUIを作ってみてほしい」「こんな場合はどうすれば?」といったアイディアを募集中です!
お気軽にコメントください。
コメント