EP.17網頁開發實戰

useState 不只是計數器:五種狀態管理場景
物件狀態、陣列狀態、衍生狀態、多個 state——從入門到實戰全覆蓋

表單 10 個欄位要寫 10 個 useState 嗎?splice 為什麼不能用? 這篇五個場景全部解清楚,讓你不再踩坑。

Joseph Chen 2026 14 min read useState · 不可變更新 · 陣列操作 · 衍生狀態

「useState(0) 我懂了。但我的表單有 10 個欄位,要寫 10 個 useState 嗎?還是用一個物件存? 我刪除陣列裡的一個項目,為什麼不能直接 todos.splice(index, 1)?」

這是每個 React 初學者都會卡住的地方。問題的根源只有一個: React 的「不可變更新」原則。 理解它,後面五個場景全部迎刃而解。

為什麼 React 要你「不可變更新」

比喻:React 就像一個視力不好的人——他不看你物件裡面的值, 只看「這個物件是不是換了一個新的」。你改了 user.age, 但還是同一個物件,他就說:沒變,不需要重新渲染。

不可變更新的核心概念
const [user, setUser] = useState({ name: 'Joseph', age: 26 });

// ❌ 直接修改:React 看不到變化
user.age = 27;
setUser(user);  // user 還是同一個物件參考 → React 不重新渲染

// ✅ 建立新物件:React 知道有變化
setUser({ ...user, age: 27 });  // 展開建立新物件 → React 重新渲染

// 記憶口訣:
// 直接改 → 跟改原本的抄本一樣,React 看不出差異
// 展開新建 → 交出一份全新的文件,React 認得出「這是新的」

直接修改(Mutate)

  • • 修改原物件的屬性
  • • 對陣列用 splice、push、pop
  • • React 偵測不到變化
  • • 畫面不更新,但資料已經壞掉

不可變更新(Immutable)

  • • 建立新物件 {...old, key: val}
  • • 陣列用 filter、map、展開
  • • React 偵測到新參考
  • • 畫面正確更新

場景 1布林開關(最簡單)

模態框開關、載入狀態、展開/收合——這類布林 state 到處都是。 寫法很簡單,但有一個值得注意的小細節:要用 functional update。

boolean-toggle.tsx
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);

// ✅ 最優寫法:用 prev(functional update)
const toggle = () => setIsOpen(prev => !prev);

// ❌ 這樣也能跑,但有潛在問題
const toggle = () => setIsOpen(!isOpen);
// 問題:isOpen 可能讀到「過期的值」(stale closure)
// 特別在 setTimeout、事件批次處理時容易踩坑

// 實際使用
function Modal() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(prev => !prev)}>
        {isOpen ? '關閉' : '開啟'} 對話框
      </button>

      {isOpen && (
        <div className="modal">
          <p>模態框內容</p>
          <button onClick={() => setIsOpen(false)}>關閉</button>
        </div>
      )}
    </>
  );
}

為什麼用 prev?

setIsOpen(prev => !prev) 讓 React 在執行 setter 的時候才去取最新的值, 而不是用閉包裡「快照」的 isOpen。在非同步操作、批次 setState 的情境下, 這個差異會讓你少踩很多坑。

場景 2物件狀態(表單最常見)

表單是物件 state 的最典型場景。多個欄位用一個物件管,更新時用展開運算子...prev保留其他欄位,只改目標欄位。

login-form.tsx
function LoginForm() {
  const [form, setForm] = useState({
    email: '',
    password: '',
    rememberMe: false,
  });

  // 通用更新方法:用計算屬性名 [field] 動態更新任意欄位
  function handleChange(field: string, value: string | boolean) {
    setForm(prev => ({
      ...prev,       // 保留其他欄位(不可變更新!)
      [field]: value // 只更新這個欄位
    }));
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    console.log('提交資料:', form);
    // form.email, form.password, form.rememberMe 都在這裡
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input
        type="email"
        value={form.email}
        onChange={e => handleChange('email', e.target.value)}
        placeholder="Email"
        className="w-full border rounded px-3 py-2"
      />
      <input
        type="password"
        value={form.password}
        onChange={e => handleChange('password', e.target.value)}
        placeholder="密碼"
        className="w-full border rounded px-3 py-2"
      />
      <label className="flex items-center gap-2 cursor-pointer">
        <input
          type="checkbox"
          checked={form.rememberMe}
          onChange={e => handleChange('rememberMe', e.target.checked)}
        />
        記住我
      </label>
      <button
        type="submit"
        className="w-full bg-violet-600 text-white py-2 rounded font-bold"
      >
        登入
      </button>
    </form>
  );
}

這樣寫的優點

一個 handleChange 搞定所有欄位,不用為每個欄位寫獨立的 handler

計算屬性名 [field] 讓函數通用化,傳入欄位名稱動態更新

...prev 展開確保不可變更新,其他欄位不受影響

場景 3陣列狀態(新增、刪除、修改)

待辦清單、購物車、留言列表——陣列 state 有三種常見操作。 記住一條規則:永遠回傳新陣列,絕對不要改原陣列

todo-list.tsx
interface Todo {
  id: number;
  text: string;
  done: boolean;
}

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: '買咖啡', done: false },
    { id: 2, text: '寫程式', done: true },
  ]);
  const [inputText, setInputText] = useState('');

  // ✅ 新增:展開現有陣列 + 新項目(回傳新陣列)
  function addTodo() {
    if (!inputText.trim()) return;
    setTodos(prev => [
      ...prev,
      { id: Date.now(), text: inputText, done: false }
    ]);
    setInputText('');
  }

  // ✅ 刪除:filter 過濾掉要刪除的(回傳新陣列)
  function deleteTodo(id: number) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  // ✅ 修改:map 找到要改的,其他不動(回傳新陣列)
  function toggleTodo(id: number) {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id
          ? { ...todo, done: !todo.done }  // 這個要改
          : todo                            // 其他不動
      )
    );
  }

  // ❌ 常見錯誤:直接 splice 修改原陣列
  function badDelete(index: number) {
    todos.splice(index, 1);   // 改了原陣列
    setTodos(todos);           // React 看到同一個陣列參考,不重新渲染!
  }

  return (
    <div className="max-w-sm space-y-3">
      <div className="flex gap-2">
        <input
          value={inputText}
          onChange={e => setInputText(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && addTodo()}
          placeholder="新增待辦..."
          className="flex-1 border rounded px-3 py-2 text-sm"
        />
        <button
          onClick={addTodo}
          className="bg-violet-600 text-white px-4 py-2 rounded text-sm font-bold"
        >
          新增
        </button>
      </div>

      {todos.map(todo => (
        <div key={todo.id} className="flex items-center gap-3 p-3 border rounded-lg">
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => toggleTodo(todo.id)}
          />
          <span className={todo.done ? 'line-through text-gray-400' : 'text-gray-800'}>
            {todo.text}
          </span>
          <button
            onClick={() => deleteTodo(todo.id)}
            className="ml-auto text-red-400 hover:text-red-600 text-sm"
          >
            刪除
          </button>
        </div>
      ))}

      <p className="text-sm text-gray-400">
        {todos.filter(t => t.done).length} / {todos.length} 完成
      </p>
    </div>
  );
}
操作正確寫法錯誤寫法(不要用)
新增[...prev, newItem]prev.push(newItem)
刪除prev.filter(item => item.id !== id)prev.splice(index, 1)
修改prev.map(item => item.id === id ? {...item, ...changes} : item)直接修改 item 屬性
排序[...prev].sort(...)prev.sort(...)(sort 會改原陣列!)

場景 4衍生狀態——不要把可以算出來的值放進 state

這是很多人寫 React 時不自覺犯的錯:把一個可以從現有 state 計算出來的值, 又另外存成獨立的 state。這樣做不只多此一舉,還會製造同步問題。

derived-state.tsx
// ❌ 不好的做法:completedCount 是衍生值,不需要單獨存
function BadTodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [completedCount, setCompletedCount] = useState(0);  // 多餘!

  function toggleTodo(id: number) {
    const newTodos = todos.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    );
    setTodos(newTodos);
    // 還要手動同步 completedCount!容易忘記
    setCompletedCount(newTodos.filter(t => t.done).length);
  }
  // 問題:兩個 state 需要手動同步 → 一旦忘記更新就不一致
}

// ─────────────────────────────────────────────────────

// ✅ 好的做法:completedCount 直接從 todos 算出來
function GoodTodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);

  // 這不是 state,只是計算值——todos 更新時它自動更新
  const completedCount = todos.filter(t => t.done).length;
  const totalCount = todos.length;
  const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;

  function toggleTodo(id: number) {
    setTodos(prev =>
      prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
    // 不需要更新 completedCount!它會自動重新計算
  }

  return (
    <div>
      <p>{completedCount} / {totalCount} 完成</p>
      <div
        style={{ width: `${progress}%` }}
        className="h-2 bg-violet-500 rounded transition-all"
      />
    </div>
  );
}

衍生狀態的判斷標準

問自己:「這個值能不能從現有的 state 或 props 直接算出來?」 如果可以,就不要把它存成 state,直接在 render 時計算就好。 如果你需要快取或避免重複計算,用 useMemo——但這是進階話題,先把「不要多存 state」的直覺建立起來。

場景 5多個 useState vs 一個物件 state

這是很多人糾結的問題。答案其實很簡單,看這些值有沒有關聯就好。

multiple-vs-object.tsx
// ✅ 多個 state:適合「完全無關」的值
function Dashboard() {
  const [count, setCount] = useState(0);
  const [username, setUsername] = useState('');
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  // 這三個值各自獨立,各自更新,分開存沒問題
}

// ─────────────────────────────────────────────────────

// ✅ 物件 state:適合「一起出現、一起更新」的值
function ProfileForm() {
  const [profile, setProfile] = useState({
    name: '',
    email: '',
    bio: '',
    avatar: '',
  });
  // 這四個都是 profile 的一部分,放一起合理

  function updateField(field: keyof typeof profile, value: string) {
    setProfile(prev => ({ ...prev, [field]: value }));
  }
}

// ─────────────────────────────────────────────────────

// ❌ 不好:把無關的值硬塞進一個物件
function BadExample() {
  const [state, setState] = useState({
    count: 0,          // 計數器
    username: '',      // 表單輸入
    isMenuOpen: false, // UI 狀態
  });
  // 這三個沒有關聯,更新時都要 ...spread,顯得囉嗦
  // 改 count 時:setState(prev => ({ ...prev, count: prev.count + 1 }))
}

選多個 state 的情況

  • 這些值各自獨立更新
  • 更新頻率差異很大(一個頻繁,一個很少動)
  • 概念上沒有關聯

選物件 state 的情況

  • 這些值概念上屬於同一個「實體」
  • 通常一起顯示、一起提交
  • 典型案例:表單欄位、API 回應資料

Functional Update:為什麼要用 prev

我們在前面幾個場景反覆用到 prev =>,現在解釋清楚為什麼。

functional-update.tsx
// 問題場景:快速點擊按鈕兩次
function Counter() {
  const [count, setCount] = useState(0);

  // ❌ 問題寫法:兩次 setCount 讀到同一個 count(因為閉包快照)
  function handleDoubleIncrement() {
    setCount(count + 1);  // count 是 0,設為 1
    setCount(count + 1);  // count 還是 0(同一個快照!),又設為 1
    // 最終結果:count = 1,不是 2
  }

  // ✅ 正確寫法:functional update 永遠拿到最新值
  function handleDoubleIncrement() {
    setCount(prev => prev + 1);  // prev 是 0,設為 1
    setCount(prev => prev + 1);  // prev 是 1(最新值),設為 2
    // 最終結果:count = 2,正確!
  }

  // 另一個常見場景:setTimeout
  function handleDelayedIncrement() {
    setTimeout(() => {
      // ❌ count 是閉包裡的舊值(0)
      setCount(count + 1);

      // ✅ prev 永遠是執行時的最新值
      setCount(prev => prev + 1);
    }, 1000);
  }

  return <button onClick={handleDoubleIncrement}>+2{count}</button>;
}

Functional Update 的使用原則

只要新的 state 依賴於舊的 state(例如 toggle、count+1、陣列展開), 就用 prev => 的形式。 如果新的 state 和舊的 state 完全無關(例如 setName('Joseph')), 直接傳值即可。

速查卡:五大場景總結

場景建議寫法核心原則
布林開關setFlag(prev => !prev)用 prev 避免 stale closure
物件更新單一欄位setState(prev => ({ ...prev, key: value }))展開保留其他欄位
陣列新增setState(prev => [...prev, newItem])展開建立新陣列
陣列刪除setState(prev => prev.filter(...))filter 不改原陣列
陣列修改setState(prev => prev.map(...))map 不改原陣列
衍生值直接算,不存 state能算的不要多存
React
useState
Hook
狀態管理
不可變更新
EP.17