上一篇順著 items 看了一次資料如何在 Component 之間傳遞。今天則把範圍縮小到 AddItemForm.tsx,看看從輸入作品名稱到加入收藏,中間經過了哪些步驟。
目前表單裡有三份 State:
const [title, setTitle] = useState('')
const [type, setType] = useState<MediaType>('manga')
const [status, setStatus] = useState<WatchStatus>('want')
它們分別用來記住作品名稱、作品類型與觀看狀態。
作品名稱的輸入框則是這樣:
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="例如:葬送的芙莉蓮"
required
/>
value={title} 表示輸入框顯示的內容來自 title。使用者輸入文字時,onChange 會取得新的內容,再透過 setTitle() 更新 State。
這種由 State 決定輸入框內容的寫法稱為「受控輸入」,目前可以先理解成:
輸入框顯示什麼內容,由 React State 負責記住。
如果只有 value={title},卻沒有使用 onChange 更新 title,輸入框就會一直收到原本的值,導致文字無法正常輸入。
React 官方文件:使用 State 控制輸入框
as作品類型與觀看狀態使用 <select>,處理方式和輸入框差不多:
<select
value={type}
onChange={(event) =>
setType(event.target.value as MediaType)
}
>
value={type} 決定目前選到的項目,選項改變時,再透過 setType() 更新 State。
仔細一看,這裡也出現了 Day 20 曾經覺得陌生的寫法:
event.target.value as MediaType
瀏覽器取得的 event.target.value 會被 TypeScript 看成一般的 string,但專案已經限制 type 只能是:
type MediaType = 'manga' | 'anime'
所以 as MediaType 是在告訴 TypeScript:
這裡取得的字串,可以當成
MediaType使用。
這種寫法稱為 Type Assertion,也就是「型別斷言」。
不過 as 只是在開發時提供型別資訊,不會真的檢查或轉換資料。如果實際拿到錯誤的內容,它也不會自動修正,所以不能只是為了讓型別錯誤消失就隨意加上 as。觀看狀態的 as WatchStatus 也是相同的用法。
TypeScript 官方文件:Type Assertions
表單透過 onSubmit 指定送出時要執行的函式:
<form className="add-form" onSubmit={handleSubmit}>
按下「加入收藏」後,就會執行 handleSubmit():
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const trimmedTitle = title.trim()
if (!trimmedTitle) return
onAdd({ title: trimmedTitle, type, status })
setTitle('')
}
這段程式依序會:
event.preventDefault() 阻止表單重新整理網頁。trim() 移除作品名稱前後的空白。onAdd 將表單資料交給 App。onAdd 後,使用 setTitle('') 清空作品名稱。其中的:
React.FormEvent<HTMLFormElement>
是在描述這個 event 來自 HTML 表單的送出事件,讓 TypeScript 知道目前正在處理哪一種事件。
idAddItemForm 不會直接更新 items,而是呼叫透過 Props 傳進來的 onAdd:
interface AddItemFormProps {
onAdd: (entry: Omit<CollectionEntry, 'id'>) => void
}
這裡也出現另一個陌生的寫法:
Omit<CollectionEntry, 'id'>
完整的 CollectionEntry 包含:
interface CollectionEntry {
id: string
title: string
type: MediaType
status: WatchStatus
}
但是表單只需要提供:
title
type
status
id 不需要由使用者填寫,而是交給 App 產生。
Omit 可以先理解成「從原本的型別中拿掉指定欄位」。所以 Omit<CollectionEntry, 'id'> 就是沿用 CollectionEntry 的格式但先拿掉 id ,這樣不需要為表單資料另外寫一份幾乎相同的型別。
TypeScript 官方文件:Omit
App 補上 id 並加入清單送出表單時,這段程式:
onAdd({ title: trimmedTitle, type, status })
會把資料交給 App 裡的 addItem():
function addItem(entry: Omit<CollectionEntry, 'id'>) {
const newItem: CollectionEntry = {
...entry,
id: crypto.randomUUID(),
}
setItems((currentItems) => [newItem, ...currentItems])
}
...entry 會放入表單提供的 title、type 和 status,crypto.randomUUID() 則替這筆收藏建立 id。
最後 setItems() 建立新的陣列,將新作品放在原本收藏資料的前面。更新完成後,新的 items 再透過 Props 傳給 CollectionList,畫面上就會出現剛才新增的作品。
把整個流程整理起來,大概是:
輸入作品名稱、類型與狀態
↓
AddItemForm 的 State 記住內容
↓
送出表單並執行 handleSubmit()
↓
透過 onAdd 把資料交給 App
↓
App 補上 id 並更新 items
↓
畫面顯示新增的作品
順著資料走過一次才比較能看出它們都在協助完成同一件事:把使用者輸入的內容整理成一筆收藏資料,再加入清單。
value 搭配 onChange,讓 State 記住表單目前的內容handleSubmit() 負責整理資料並呼叫 onAdd
as 用來告訴 TypeScript 如何看待一份資料的型別Omit<CollectionEntry, 'id'> 表示沿用原本的型別,但先拿掉 id
App 會補上 id,再透過 setItems() 將作品加入清單下篇見!