[情境任務]
解師傅:我們的餐廳生意越來越好了,為了不讓客人排隊,我想客製一個點餐機~
小當家:啥?這是什麼玩意?
解師傅:直接在點餐機上選擇餐點跟輸入客人的資料,我們既不用自己點餐,客人也不用排隊,根本一舉兩得阿!
小當家:解師傅,你真是個天才!
現在我們已經有餐點了,還需要方便客人填寫資料的表單,一起動手做吧!

還記得在 DAY 2 時有提到,React 不做資料綁定,所以在資料有變更時,常常會用 onChange 去做資料的更新
由於一個表單可能會有多個欄位,所以這邊使用 object 來當預設值,方便之後擴充
為了不混淆,會將每個欄位拆開來看,以下分別為各種類型 input、select、radio、checkbox、file 的欄位運用
import { useState } from "react";
export default function App() {
  const [form, setForm] = useState({
    name: ""
  });
  const changeName = (e) => {
    setForm((state) => ({
      ...state,
      name: e.target.value
    }));
  };
  return (
    <form>
      <label htmlFor="name">姓名</label>
      <input
        id="name"
        type="text"
		name="name"
        value={form.name}
        onChange={changeName}
      />
    </form>
  );
}
input 會接收 value 和 onChange 事件,如 input 輸入的值變更,setForm 會將 form.name 變更為新的值,以達成 input 雙向綁定
import { useState } from "react";
export default function App() {
  const [form, setForm] = useState({
    number: ""
  });
  const changeNumber = (e) => {
    setForm((state) => ({
      ...state,
      number: parseInt(e.target.value, 10)
    }));
  };
  return (
    <form>
      <label htmlFor="num">此次用餐人數</label>
      <input
        id="num"
        type="number"
		name="number"
        value={form.number}
        onChange={changeNumber}
      />
    </form>
  );
}
「value 傳入的值一定會是字串」,所以如想要的值為其他型別,要記得轉型,上例的 input 為 Number 型態,需再使用 parseInt 將字串轉型為 number
import { useState } from "react";
export default function App() {
  const age = [
    "18歲以下",
    "18歲~29歲",
    "30歲~39歲",
    "40歲~49歲",
    "50歲~59歲",
    "60歲以上"
  ];
  const [form, setForm] = useState({
    age: age[0]
  });
  const changeAge = (e) => {
    setForm((state) => ({
      ...state,
      age: e.target.value
    }));
  };
  return (
    <form>
      <label>請選擇您的年齡區間</label>
      <select name="age" value={form.age} onChange={changeAge}>
        {age.map((item) => (
          <option key={item.value} value={item}>{item}</option>
        ))}
      </select>
	  <h1>您選擇了: {form.age}</h1>
    </form>
  );
}
只有字串的陣列很單純,預設值設定第 0 筆,並用 map 渲染出列表,select 接收 value 和 onChange 事件,setForm 會將 form.age 變更為新的值,達成 select 雙向綁定
import { useState } from "react";
export default function App() {
  const age = [
    { label: "18歲以下", value: "0" },
    { label: "18歲~29歲", value: "1" },
    { label: "30歲~39歲", value: "2" },
    { label: "40歲~49歲", value: "3" },
    { label: "50歲~59歲", value: "4" },
    { label: "60歲以上", value: "5" }
  ];
  const [form, setForm] = useState({
    age: age[0].value
  });
  const changeAge = (e) => {
    setForm((state) => ({
      ...state,
      age: e.target.value
    }));
  };
  return (
    <form>
      <label>請選擇您的年齡區間</label>
      <select name="age" value={form.age} onChange={changeAge}>
        {age.map((item) => (
          <option key={item.value} value={item.value}>
            {item.label}
          </option>
        ))}
      </select>
      <h1>您選擇了: {age.find((item) => item.value === form.age).label}</h1>
    </form>
  );
}
有時候 select 的文字,跟要傳入的 value 是不一樣的,這時候可以用物件陣列,做法跟綁定字串陣列差不多,只要綁定物件裡的 value 就可以了
特別注意的是,要顯示選擇的項目,因為 form.age 綁定的是 value 值,我們想顯示 label 需要從 age 陣列去找 value 跟 form.age 相同的的物件,再取得物件的 label
import { useState } from "react";
export default function App() {
  const [form, setForm] = useState({
    gender: "male",
  });
  const changeGender = (e) => {
    setForm((state) => ({
      ...state,
      gender: e.target.value
    }));
  };
  return (
    <form>
      <label>性別</label>
      <div>
        <input
          type="radio"
          id="male"
		  name="gender"
          value="male"
          onChange={changeGender}
          checked={form.gender === "male"}
        />
        <label htmlFor="male">男性</label>
      </div>
      <div>
        <input
          type="radio"
          id="female"
		  name="gender"
          value="female"
          onChange={changeGender}
          checked={form.gender === "female"}
        />
        <label htmlFor="female">女性</label>
      </div>
    </form>
  );
}
利用 value 和 onChange 達成雙向綁定,radio 還有 checked 屬性,依據 form.gender 去判斷是否 checked
import { useState } from "react";
export default function App() {
	const gender = [
    { label: "男性", value: "male" },
    { label: "女性", value: "female" }
  ];
  const [form, setForm] = useState({
    gender: "male",
  });
  const changeGender = (e) => {
    setForm((state) => ({
      ...state,
      gender: e.target.value
    }));
  };
  return (
    <form>
      <label>性別</label>
      {gender.map((item) => (
        <div key={item.value}>
          <input
            type="radio"
            id={item.value}
			name="gender"
            value={item.value}
            onChange={changeGender}
            checked={form.gender === item.value}
          />
          <label htmlFor={item.value}>
            {item.label}
          </label>
        </div>
      ))}
    </form>
  );
}
將項目整理成陣列,用 map 渲染列表,並綁定 value、checked 的值
import { useState } from "react";
export default function App() {
  const purpose = [
    { label: "約會聚餐", value: "date" },
    { label: "朋友聚會", value: "friend" },
    { label: "商務用餐", value: "business" },
    { label: "慶祝生日", value: "birthday" },
    { label: "其他", value: "others" }
  ];
  const [form, setForm] = useState({
    purpose: {
      date: false,
      friend: false,
      business: false,
      birthday: false,
      others: false
    }
  });
  const changePurpose = (e) => {
    const key = e.target.value;
    setForm((state) => ({
      ...state,
      purpose: {
        ...state.purpose,
        [key]: !state.purpose[key]
      }
    }));
  };
  return (
    <form>
      <label>此次用餐目的</label>
	  {purpose.map((item) => (
        <div key={item.value}>
          <input
            type="checkbox"
			name="purpose"
            value={item.value}
            id={item.value}
            checked={form.purpose[item.value]}
            onChange={changePurpose}
          />
          <label htmlFor={item.value}>
            {item.label}
          </label>
        </div>
      ))}
    </form>
  );
}
綁定物件的 boolean 值去控制是否 checked,並在 setForm 做開關的動作
import { useState } from "react";
export default function App() {
  const purpose = [
    { label: "約會聚餐", value: "date" },
    { label: "朋友聚會", value: "friend" },
    { label: "商務用餐", value: "business" },
    { label: "慶祝生日", value: "birthday" },
    { label: "其他", value: "others" }
  ];
  const [form, setForm] = useState({
    purpose: []
  });
  const changePurpose = (e) => {
    const value = e.target.value;
    setForm((state) => {
      if (state.purpose.includes(value)) {
        return {
          ...state,
          purpose: state.purpose.filter((item) => item !== value)
        };
      } else {
        return {
          ...state,
          purpose: [...state.purpose, value]
        };
      }
    });
  };
  return (
    <form>
	  <label>此次用餐目的</label>
      {purpose.map((item, idx) => (
        <div key={item.value}>
          <input
            type="checkbox"
            value={item.value}
			name="purpose"
            id={item.value}
            checked={form.purpose.includes(item.value)}
            onChange={changePurpose}
          />
          <label htmlFor={item.value}>
            {item.label}
          </label>
        </div>
      ))}
    </form>
  );
}
陣列會傳入有 checked 的 value,如點擊已 checked 的項目,則會用 filter 過濾掉此 value
import { useState } from "react";
export default function App() {
  const [form, setForm] = useState({
    file: ""
  });
  const changeFile = (e) => {
    // 取得第0筆檔案
    const file = e.target.files[0];
    // FileReader 讀取瀏覽器選中的檔案
    const fileReader = new FileReader();
    // 讀取完改變 img
    fileReader.addEventListener("load", fileLoad);
    // 將圖片繪出,轉換成 Base64 編碼
    fileReader.readAsDataURL(file);
  };
  const fileLoad = (e) => {
    // 此處的 e 為 fileReader
    setForm((state) => ({
      ...state,
      file: e.target.result
    }));
  };
  return (
    <form>
      <label>相關圖片</label>
      <div>
        <input
          type="file"
          id="upload"
		  name="file"
          onChange={changeFile}
        />
        <img src={form.file} width="100%" alt="" />
      </div>
    </form>
  );
}
type 為 file 時,沒辦法用 value 指定,透過 fileReader 讀取檔案,再轉換給 form.file
因為 changeName、changeAge、changeGender 的 function 邏輯都是一樣的,所以可以在 onChange 時統一讀取同一個 function,如下讀取 changeValue,取得欄位的 name 屬性,並賦予新值
const changeValue = (e) => {
  const name = e.target.name;
  setForm((state) => ({
    ...state,
    [name]: e.target.value
  }));
};
完整 form 表單如下
import { useState } from "react";
export default function App() {
  const age = [
    { label: "18歲以下", value: "0" },
    { label: "18歲~29歲", value: "1" },
    { label: "30歲~39歲", value: "2" },
    { label: "40歲~49歲", value: "3" },
    { label: "50歲~59歲", value: "4" },
    { label: "60歲以上", value: "5" }
  ];
  const gender = [
    { label: "男性", value: "male" },
    { label: "女性", value: "female" }
  ];
  const purpose = [
    { label: "約會聚餐", value: "date" },
    { label: "朋友聚會", value: "friend" },
    { label: "商務用餐", value: "business" },
    { label: "慶祝生日", value: "birthday" },
    { label: "其他", value: "others" }
  ];
  const [form, setForm] = useState({
    name: "",
    number: "",
    gender: "male",
    age: age[0].value,
    purpose: [],
    file: ""
  });
  const changeNumber = (e) => {
    setForm((state) => ({
      ...state,
      number: parseInt(e.target.value, 10)
    }));
  };
  const changeValue = (e) => {
    const name = e.target.name;
    setForm((state) => ({
      ...state,
      [name]: e.target.value
    }));
  };
  const changePurpose = (e) => {
    const value = e.target.value;
    setForm((state) => {
      if (state.purpose.includes(value)) {
        return {
          ...state,
          purpose: state.purpose.filter((item) => item !== value)
        };
      } else {
        return {
          ...state,
          purpose: [...state.purpose, value]
        };
      }
    });
  };
  const changeFile = (e) => {
    // 取得第0筆檔案
    const file = e.target.files[0];
    // FileReader 讀取瀏覽器選中的檔案
    const fileReader = new FileReader();
    // 讀取完改變 img
    fileReader.addEventListener("load", fileLoad);
    // 將圖片繪出,轉換成 Base64 編碼
    fileReader.readAsDataURL(file);
  };
  const fileLoad = (e) => {
    // 此處的 e 為 fileReader
    setForm((state) => ({
      ...state,
      file: e.target.result
    }));
  };
  return (
    <div>
      <h1>React 熱炒店訂購單</h1>
      <form>
        <div>
          <label htmlFor="name">
            姓名
          </label>
          <input
            id="name"
            type="text"
            name="name"
            value={form.name}
            onChange={changeValue}
          />
        </div>
        <div>
          <label htmlFor="num">
            此次用餐人數
          </label>
          <input
            id="num"
            type="number"
            value={form.number}
            onChange={changeNumber}
          />
        </div>
        <div>
          <label>性別</label>
          <div>
            {gender.map((item) => (
              <div key={item.value}>
                <input
                  type="radio"
                  name="gender"
                  id={item.value}
                  value={item.value}
                  onChange={changeValue}
                  checked={form.gender === item.value}
                />
                <label htmlFor={item.value}>
                  {item.label}
                </label>
              </div>
            ))}
          </div>
        </div>
        <div>
          <label>請選擇您的年齡區間</label>
          <select
            name="age"
            value={form.age}
            onChange={changeValue}
          >
            {age.map((item) => (
              <option key={item.value} value={item.value}>
                {item.label}
              </option>
            ))}
          </select>
          <h6>
            您選擇了: {age.find((item) => item.value === form.age).label}
          </h6>
        </div>
        <div>
          <label>此次用餐目的</label>
          <div>
            {purpose.map((item, idx) => (
              <div key={item.value}>
                <input
                  type="checkbox"
                  value={item.value}
                  id={item.value}
                  checked={form.purpose.includes(item.value)}
                  onChange={changePurpose}
                />
                <label htmlFor={item.value}>
                  {item.label}
                </label>
              </div>
            ))}
          </div>
        </div>
        <div>
          <label>相關圖片</label>
          <div>
            <input
              type="file"
              id="upload"
              onChange={changeFile}
            />
            <button
              type="button"
              id="upload"
            >
              上傳
            </button>
            <img src={form.file} width="100%" />
          </div>
        </div>
      </form>
    </div>
  );
}
打開 codesandbox 程式碼範例 一起試看看吧!
[任務解題]
依照上面的範例,加上了 className,已完成訂購單囉!你真是幫了餐廳一個大忙!
表單的處理在 React 也是一門學問,React 不像其他框架有做雙向綁定的模版,所以利用 onChange 可以幫助我們綁定新的值,就達到雙向綁定的效果囉! 
本文將同步更新至我的部落格
Lala 的前端大補帖