感謝 iT 邦幫忙與博碩文化,本系列文章已出版成書「從 Hooks 開始,讓你的網頁 React 起來」,首刷版稅將全額贊助 iT 邦幫忙鐵人賽,歡迎前往購書,鼓勵筆者撰寫更多優質文章。
useRef
, controlled components
, uncontrolled components
, form
昨天已經建立好了天氣地區的設定頁面,今天要來說明在 React 中基本的表單處理以及 useRef
的使用。
不知道你是否還有印象,之前在「Day 10 - 換算起來吧 - 資料綁定與組件拆分」網速換算器的單元中,我們就曾經使用過表單元素 <input type="number" />
,當時透過 onChange
搭配 setState
的方式來操作表單的資料,但在當時我們還沒有進一步說明 React 中表單處理的概念。
在 React 中表單元素的處理主要可以分成兩種 Controlled 和 Uncontrolled 這兩種,這裡關於 Controlled 和 Uncontrolled 指的是「資料受不受到 React 所控制」,也就是「受 React 所控制的資料(Controlled)」或「不受 React 所控制的資料(Uncontrolled)」。
之所以在表單元素上會區分「受 React 控制的資料」和「不受 React 控制的資料」,主要是因為在瀏覽器中,像是 <input />
這類的表單元素本身就可以保有自己的資料狀態,這也就是爲什麼當我們在 <input />
中輸入文字後,可以直接透過 JavaScript 選到該 input 元素後,再取出該元素的值,因為使用者輸入的內容(資料)可以直接保存在 <input />
元素內。
以下面程式碼舉例來說:
document.querySelector
選到該表單元素value
屬性,就可以知道該 <input />
欄位中填入的值為何<input type="text" id="name"/>
<script>
const inputName = document.querySelector("#name");
inputName.addEventListener("input", e => console.log(e.target.value));
</script>
但到了 React 時,React 就可以幫我們處理資料狀態,我們可以把表單內使用者輸入的資料交給 React,在使用者輸入資料的同時驗證使用者輸入內容的有效性,並做瀏覽器畫面的更新(例如,輸入的內容有誤時跳出提示訊息)。
這種把表單資料交給 React 來處理的就稱作 Controlled Components,也就是受 React 控制的資料;相對地,如果不把表單資料交給 React,而是像過去一樣,選取到該表單元素後,才從該表單元素取出值的這種做法,就稱作 Uncontrolled Components,也就是不受 React 控制的資料。
針對表單元素, React 會建議我們使用 Controlled Components,基本上使用 Controlled Components 和 Uncontrolled Components 都能達到一樣或類似的效果,但是當我們需要對資料有更多的控制或提示畫面的處理時,使用 Controlled Components 會來得容易的多。
⚠️ 提醒:多數的表單元素都可以交給 React 處理,除了上傳檔案用的
<input type="file" />
例外,因為該元素有安全性的疑慮,JavaScript 只能取值而不能改值,也就是透過 JavaScript 可以知道使用者選擇要上傳的檔案為何(取值),但不能去改變使用者要上傳的檔案(改值)。因此對於檔案上傳用的<input type="file" />
只能透過 Uncontrolled Components 的方式處理。
現在就來讓我們看看如何實作這兩者表單處理的方式。一樣可以先在 CodeSandbox 打開昨天完成的程式碼 Weather APP - Add Setting Page,複製一份來開始今天的練習。
今天我們會練習表單的處理,當使用者在天氣設定頁點選「儲存」的時候,可以在瀏覽器的開發者工具中看到使用者欲儲存的地區,而實際上資料的保存則會到明天再繼續說明。
針對表單元素,React 會建議我們使用 Controlled Components,也就是把表單的資料儲存在該 React 組件內交給他來處理,這個做法就和先前在「Day 10 - 換算起來吧 - 資料綁定與組件拆分」網速換算器中使用的方式相同。
因為要將資料交給 React 處理,所以會先透過 useState
來建立保存資料狀態的地方,接著在表單元素上透過 onChange
事件來取得該表單元素當前的值,並且馬上更新到 React 組件的資料狀態內。
套用到天氣設定頁面就像這樣:
useState
useState
取得 locationName
和 setLocationName
,將預設值先設為空onChange
事件來監聽使用者輸入的資料,並且當事件觸發時呼叫 handleChange
handleChange
函式,當使用者輸入資料時,把資料內容透過 setLocation
更新 React 內部的資料狀態提示:這裡不把資料狀態取名為
location
的原因在於瀏覽器本身就有一個window.location
物件,因此當你直接在瀏覽器的 console 面版中輸入location
是會得到內容的,為了避免可能的錯誤,取名為locationName
// ./src/WeatherSetting.js
// STEP 1:從 react 中載入 useState
import React, { useState } from 'react';
// ...
const WeatherSetting = ({ setCurrentPage }) => {
// STEP 2:定義 locationName,預設值先帶為空
const [locationName, setLocationName] = useState('');
// STEP 4:定義 handleChange 要做的事
const handleChange = (e) => {
console.log(e.target.value);
// STEP 5:把使用者輸入的內容更新到 React 內的資料狀態
setLocationName(e.target.value);
};
return (
<WeatherSettingWrapper>
{console.log('render')}
<Title>設定</Title>
<StyledLabel htmlFor="location">地區</StyledLabel>
{/* STEP 3:使用 onChange 事件來監聽使用者輸入資料 */}
<StyledInputList
list="location-list"
id="location"
name="location"
onChange={handleChange}
/>
{/* ... */}
</WeatherSettingWrapper>
);
};
export default WeatherSetting;
這裡可以留意的是,每當我們在表單中輸入內容時,因為會呼叫到 setLocationName
方法,所以實際上畫面只要使用者輸入的內容有變動,畫面就會重新渲染,有興趣的話可以在 return
內透過 console.log('render')
來觀察一下:
先來建立使用者點擊儲存後的操作流程,我們希望當使用者輸入的是有效的(中央氣象局 API 有提供資料)地區時,才將資料保存下來,否則顯示錯誤提示:
handleSave
方法,用來處理當使用者點擊儲存時要做的事handleSave
中透過陣列的 includes
方法來判斷使用者輸入的資料有無包含在該陣列中setCurrentPage
導回到天氣資訊頁alert
顯示錯誤訊息,並停在原頁面handleSave
函式綁定在儲存按鈕的點擊 onClick
事件上// ./src/WeatherSetting.js
// ...
const locations = [
'嘉義縣', '新北市', '嘉義市', '新竹縣', '新竹市',
'臺北市', '臺南市', '宜蘭縣', '苗栗縣', '雲林縣',
'花蓮縣', '臺中市', '臺東縣', '桃園市', '南投縣',
'高雄市', '金門縣', '屏東縣', '基隆市', '澎湖縣',
'彰化縣', '連江縣',
];
const WeatherSetting = ({ setCurrentPage }) => {
const [locationName, setLocationName] = useState('臺北市');
const handleChange = (e) => {
setLocationName(e.target.value);
};
// STEP 1:定義 handleSave 方法
const handleSave = () => {
// STEP 2:判斷使用者填寫的地區是否包含在 locations 陣列內
if (locations.includes(locationName)) {
// TODO: 儲存地區資訊...
console.log(`儲存的地區資訊為:${locationName}`);
// STEP 3:透過 setCurrentPage 導回天氣資訊頁
setCurrentPage('WeatherCard');
} else {
// STEP 4:若不包含在 locations 內,則顯示錯誤提示
alert(`儲存失敗:您輸入的 ${locationName} 並非有效的地區`);
return;
}
};
return (
<WeatherSettingWrapper>
{/* ... */}
<ButtonGroup>
<Back onClick={() => setCurrentPage('WeatherCard')}>返回</Back>
{/* STEP 5:將 handleSave 綁定在 onClick 事件 */}
<Save onClick={handleSave}>儲存</Save>
</ButtonGroup>
</WeatherSettingWrapper>
);
};
export default WeatherSetting;
現在的畫面就會像下面這樣,若輸入的是有效地區則會導回到天氣資訊頁,否則會顯示錯誤提示:
現在因為還沒實際儲存地區資訊,所以回到天氣資訊頁時仍然會看到寫「臺北」,感覺怪怪的,我們會在明天接著來處理這個部分。
假設使用者前一次已經有儲存過地區資料,當使用者在進到設定頁的時,地區欄位應該就會直接帶出上一次他填寫的資訊,在 Controlled Components 中可以怎麼做呢?
若要讓這個地區欄位的 <input>
在頁面呈現時就帶有預設值得話,我們可以直接在 <input>
中加上 value
屬性,React 會自動把這個 value
帶入的值當作該欄位的預設值呈現出來。
舉例來說,若想要讓使用者一進到設定頁時就看到「臺北市」已經被選擇的話,可以這麼做:
useState
的地方把 locationName
的預設值設為「臺北市」value
屬性就可以把該 <input>
欄位帶入預設值,如果是 <select>
或這裡使用的 <datalist>
元素,React 會很聰明的直接和 <option>
欄位內的 value
做對應並呈現出來// ./src/WeatherSetting.js
// ...
const WeatherSetting = ({ setCurrentPage }) => {
// STEP 1:讓 locationName 的預設值為 '臺北市'
const [locationName, setLocationName] = useState('臺北市');
// ...
return (
<WeatherSettingWrapper>
{/* ... */}
{/* STEP 2:透過 value 帶入該欄位的預設值 */}
<StyledInputList
// ...
value={locationName}
/>
<datalist id="location-list">
{locations.map((location) => (
<option value={location} key={location} />
))}
</datalist>
{/* ... */}
</WeatherSettingWrapper>
);
};
export default WeatherSetting;
這時候當使用者一進到設定頁面時,地區就會帶有預設值。你也可以試著透過修改 value
內的值來嘗試改變表單預設呈現的資料:
上面使用的是 Controlled Component 的作法,一般來說除非是 <input type="file">
這種檔案上傳的欄位之外,多會使用 Controlled Component 來做。
但有些時候可能只是想要很簡單的去取得表單中某個欄位的值,或者是有一些情況下需要直接操作 DOM(例如,音樂播放器中有許多方法是直接綁在 <video>
元素上的),這時可以這麼做呢?
前面有提到 Uncontrolled Components 並不會把資料交給 React 管理,而是自己選到該 <input />
元素後去從該 DOM 元素中把值取出來。在 React 中若想要選取到某一元素時,就可以使用 useRef
這個 React Hooks。
這裡我們就同樣以 WeatherSetting 這個表單為例,只是這次把它作為 Uncontrolled Components 搭配 useRef
來使用。
useRef
的基本用法如下:
useRef
內可以放進一個預設值(initialValue)useRef
會回傳一個物件(refContainer),這個物件不會隨著每一次畫面重新渲染而指稱到不同的物件,而是可以一直指稱到同一個物件refContainer.current
屬性可以取得預設值或更動後的值const refContainer = useRef(initialValue);
如果是要把 useRef
當成 document.querySelector
來選取到某一元素的話,可以在該 HTML 元素上使用 ref
屬性,並把 useRef
回傳的物件放進去即可,例如:
const InputElement = () => (
<input ref={refContainer} />
)
這時候剛剛建立的 refContainer.current
就會指稱到這個 <input>
元素。
讓我們實際套用到地區設定頁面來看看可以怎麼用:
react
中取出 useRef
這個 Hook 來用useRef
來建立可以一直被參照到的物件,將這個回傳的物件取名為 inputLocationRef
<input>
的地方,不需要在使用 onChange
事件隨時更新 React 的資料狀態,而是透過 ref={inputLocationRef}
讓 inputLocationRef.current
可以指稱到這個 input 欄位inputLocationRef.current
即可取得剛剛透過 ref
指稱的元素,並且透過 inputLocationRef.current.value
就可以取得該欄位的值<input>
欄位中使用 defaultValue
// STEP 1:從 react 中載入 useRef
import React, { useRef } from 'react';
import styled from '@emotion/styled';
// ...
const WeatherSetting = ({ setCurrentPage }) => {
// STEP 2:使用 useRef 建立一個 ref,取名為 inputLocationRef
const inputLocationRef = useRef(null);
const handleSave = () => {
// STEP 4:
// 透過 inputLocationRef.current 可以指稱到該 input 元素
// 透過 inputLocationRef.current.value 即可取得該 input 元素的值
const locationName = inputLocationRef.current.value;
console.log(locationName);
// ...
};
return (
<WeatherSettingWrapper>
{console.log('render')}
<Title>設定</Title>
<StyledLabel htmlFor="location">地區</StyledLabel>
{/* STEP 3:將 useRef 回傳的物件,指稱為該 input 元素 */}
{/* STEP 5:在 uncontrolled components 中可以使用 defaultValue 定義預設值 */}
<StyledInputList
// ...
ref={inputLocationRef}
defaultValue="臺南市"
/>
<datalist id="location-list">
{locations.map((location) => (
<option value={location} key={location} />
))}
</datalist>
<ButtonGroup>
<Back onClick={() => setCurrentPage('WeatherCard')}>返回</Back>
<Save onClick={handleSave}>儲存</Save>
</ButtonGroup>
</WeatherSettingWrapper>
);
};
export default WeatherSetting;
補充:當我們把
useRef
回傳的物件透過rel
的方式放到 HTML 元素中時,就很像是用document.querySelector
去選到該元素後,保存在useRef
回傳物件的current
屬性內。
現在操作的畫面就會如同先前使用 Controlled Components 的流程一樣,但要特別留意的是:「當 input 欄位內的資料有變動時,並不像 Controlled Component 一樣會促發畫面重新渲染」,因此,若有重新渲染畫面的需求,建議還是使用 Controlled Component 來處理:
這部分完整的程式碼一樣可以到 CodeSandbox 上檢視 Weather APP - Setting Page with Uncontrolled Component by useRef。
實際上 useRef
做的事就是建立並回傳一個帶有 current
屬性的物件,因此和自己去定義一個 { current: '' }
是一樣的,但重要的差別在於,我們知道每一次 React 組件在重新呼叫並渲染時,該組件中的內容雖然看起來一模一樣,但實際上內部的函式和物件其實都是全新獨立的,而透過 useRef
就可以幫助開發者,即使在組建重新渲染後,仍可以去取得同一個物件,並取出內部的值來用。
我們曾經在「Day 20 - 在 useEffect 中使用呼叫需被覆用的函式 - useCallback 的使用」中提到,Function Component 每次重新渲染時,函式內容雖然長得一模一樣,但實際上是完全獨立不同的執行環境,而這也是為什麼當時要使用
useCallback
的緣故。
具體來說,useRef
除了可以搭配 ref
指稱到某一 HTML 元素來使用之外,當我們在 React 組件中想要定義一些「變數」,但當這些變數改變時,又不需要像 state
一樣會重新導致畫面渲染的話,就很適合使用 useRef
。
例如說,有些時候想要看某個組件被重新渲染了幾次,就可以類似這樣寫:
// STEP 1:從 react 中載入 useRef
import React, { useRef } from 'react';
const RefExample = () => {
// STEP 2:將 renderCount 的預設值設為 0
const renderCount = useRef(0);
return (
<div>
{/* STEP 3:每次畫面渲染時就將 renderCount.current + 1 */}
{renderCount.current += 1}
{/* STEP 4:顯示這是該組件第幾次重新渲染 */}
{console.log('render', renderCount.current)}
<h1> Hello, React </h1>
</div>
)
}
透過 useRef
便可以在 Functional Component 中定義不會導致畫面重新渲染的變數。
今天學習到了在 React 中如何透過 Controlled 或 Uncontrolled 的方式進行基本的表單處理,並且了解 useRef
的使用。明天會再說明如何將地區資訊保存下來,並更新資料的拉取。
今天的程式碼一樣可以參考放置於 CodeSandbox 上的連結:
在練習 controlled component 設定預設值的部分是在 input tag 裡面使用 value,但是這樣除非先把預設刪掉才可以顯示下拉選單的值,建議改用 placeholder 就可以不用把預設刪掉顯示下拉選單。