Skip to content

useImmer

use-immer 是一個基於 immer 實作出用來操作狀態的 hook 的 library。它可以讓我們用類似「可變」的方式來更新不可變的狀態,從而簡化狀態更新的邏輯。

安裝

sh
npm install immer use-immer
sh
pnpm add immer use-immer

API

useImmer

useImmer 的使用方式和 useState 類似,函式回傳一個陣列,第一個元素是狀態值,第二個元素是一個用來更新狀態的函式。

ts
const [state, updateState] = useImmer(initialState);

參數

  • initialState:初始狀態值或是一個回傳初始狀態值的函式。

回傳值

  • state:目前的狀態值。
  • updateState:用來更新狀態的函式。它接受一個「製作者函式」(producer function)作為參數,該函式接受一個「草稿狀態」(draft state)作為參數,我們可以在這個函式中直接修改草稿狀態,immer 會自動產生新的不可變狀態。

useImmerReducer

useImmerReduceruseReducer 的 immer 版本,使用方式也和 useReducer 類似。

ts
const [state, dispatch] = useImmerReducer(reducer, initialState, init?);

使用方式

處理巢狀物件狀態

我們先來看一個範例,使用 useState 來管理一個巢狀物件的狀態時,每次更新都需要手動展開物件,這樣會讓程式碼變得冗長且難以維護。

tsx
import { useState } from 'react';

interface Profile {
  name: string;
  email: string;
  address: {
    city: string;
    country: string;
  };
  marketing: {
    newsletter: boolean;
    sms: boolean;
  };
}

const initialProfile: Profile = {
  name: 'Sheep',
  email: 'abc@example.com',
  address: { city: 'Taipei', country: 'TAIWAN' },
  marketing: { newsletter: true, sms: false },
};

export default function App() {
  const [profile, setProfile] = useState<Profile>(initialProfile);

  const updateName = (name: string) => {
    setProfile((prev) => ({ ...prev, name }));
  };

  const updateCity = (city: string) => {
    setProfile((prev) => ({
      ...prev,
      address: { ...prev.address, city },
    }));
  };

  const toggleNewsletter = () => {
    setProfile((prev) => ({
      ...prev,
      marketing: {
        ...prev.marketing,
        newsletter: !prev.marketing.newsletter,
      },
    }));
  };

  const reset = () => setProfile(initialProfile);

  return (
    <main style={{ fontFamily: 'sans-serif', padding: '24px' }}>
      <h1>useState deep updates</h1>
      <section style={{ display: 'grid', gap: '12px', maxWidth: 440 }}>
        <label style={{ display: 'grid', gap: 4 }}>
          <span>Name</span>
          <input
            value={profile.name}
            onChange={(e) => updateName(e.target.value)}
            style={{ padding: '8px 10px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: 4 }}>
          <span>City</span>
          <input
            value={profile.address.city}
            onChange={(e) => updateCity(e.target.value)}
            style={{ padding: '8px 10px' }}
          />
        </label>

        <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <input
            type="checkbox"
            checked={profile.marketing.newsletter}
            onChange={toggleNewsletter}
          />
          <span>Subscribe to newsletter</span>
        </label>

        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={reset} style={{ padding: '8px 12px' }}>
            Reset
          </button>
        </div>
      </section>

      <pre
        style={{
          marginTop: 16,
          padding: 12,
          background: '#111',
          color: '#e5e5e5',
          borderRadius: 8,
          maxWidth: 520,
          overflowX: 'auto',
        }}
      >
        {JSON.stringify(profile, null, 2)}
      </pre>
    </main>
  );
}

現在我們來用 useImmer 重寫這個範例:

tsx
import { useImmer } from 'use-immer';

export default function App() {
  const [profile, updateProfile] = useImmer<Profile>(initialProfile);

  const updateName = (name: string) => {
    updateProfile((draft) => {
      draft.name = name;
    });
  };

  const updateCity = (city: string) => {
    updateProfile((draft) => {
      draft.address.city = city;
    });
  };

  const toggleNewsletter = () => {
    updateProfile((draft) => {
      draft.marketing.newsletter = !draft.marketing.newsletter;
    });
  };

  const reset = () => updateProfile(() => initialProfile);

  // ...
}

可以看到使用 useImmer 後,我們可以直接在 update 函式中拿到一個可變的 draft 狀態,然後直接修改它的屬性,程式碼變得更簡潔易讀。

處理陣列狀態

透過 immer,我們也可以更方便地操作陣列狀態,所有原生的陣列方法都可以直接使用。

tsx
import { useState } from 'react';
import { useImmer } from 'use-immer';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export default function App() {
  const [text, setText] = useState('');
  const [todos, updateTodos] = useImmer<Todo[]>([
    { id: 1, text: 'Learn useImmer', completed: false },
    { id: 2, text: 'Refactor setState code', completed: true },
  ]);

  const addTodo = (value: string) => {
    const next = value.trim();
    if (!next) return;
    updateTodos((draft) => {
      draft.push({ id: Date.now(), text: next, completed: false });
    });
    setText('');
  };

  const toggleTodo = (id: number) => {
    updateTodos((draft) => {
      const target = draft.find((todo) => todo.id === id);
      if (target) {
        target.completed = !target.completed;
      }
    });
  };

  const deleteTodo = (id: number) => {
    updateTodos((draft) => {
      const index = draft.findIndex((todo) => todo.id === id);
      if (index !== -1) {
        draft.splice(index, 1);
      }
    });
  };

  const clearCompleted = () => {
    updateTodos((draft) => draft.filter((todo) => !todo.completed));
  };

  const remaining = todos.filter((todo) => !todo.completed).length;
  const completed = todos.length - remaining;

  return (
    <main>
      <h1>Todo List</h1>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          addTodo(text);
        }}
        style={{ display: 'flex', gap: 8, marginTop: 12 }}
      >
        <input
          value={text}
          onChange={(event) => setText(event.target.value)}
          placeholder="Add a task..."
          style={{ flex: 1 }}
        />
        <button type="submit">Add</button>
      </form>

      <section style={{ marginTop: 12 }}>
        <strong>{todos.length} total</strong> · <span>{remaining} remaining</span>
        <button
          type="button"
          onClick={clearCompleted}
          disabled={completed === 0}
          style={{ marginLeft: 8 }}
        >
          Clear completed
        </button>
      </section>

      <ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
        {todos.map((todo) => (
          <li
            key={todo.id}
            style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0' }}
          >
            <input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
            <span
              style={{
                flex: 1,
                textDecoration: todo.completed ? 'line-through' : 'none',
                color: todo.completed ? '#888' : 'inherit',
              }}
            >
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <p style={{ color: '#555', marginTop: 12 }}>Nothing here yet. Add your first task!</p>
      )}
    </main>
  );
}

useImmerReducer 範例

我們直接拿之前在 useReducer 的購物車範例 來改:

tsx
import { useReducer } from 'react'; 
import { useImmerReducer } from 'use-immer'; 

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState); 
  const [state, dispatch] = useImmerReducer(cartReducer, initialState); 
  // ...
ts
// ...

export function cartReducer(draft: CartState, action: CartAction) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = draft.items.find((item) => item.id === action.payload.id);

      if (existingItem) {
        existingItem.quantity += 1;
      } else {
        draft.items.push({ ...action.payload, quantity: 1 });
      }

      draft.totalAmount = calculateTotal(draft.items);
      break;
    }

    case 'REMOVE_ITEM': {
      draft.items = draft.items.filter((item) => item.id !== action.payload.id);
      draft.totalAmount = calculateTotal(draft.items);
      break;
    }

    case 'UPDATE_QUANTITY': {
      const { id, quantity } = action.payload;

      if (quantity <= 0) {
        draft.items = draft.items.filter((item) => item.id !== id);
        draft.totalAmount = calculateTotal(draft.items);
        break;
      }

      const item = draft.items.find((cartItem) => cartItem.id === id);
      if (item) {
        item.quantity = quantity;
        draft.totalAmount = calculateTotal(draft.items);
      }

      break;
    }

    case 'CLEAR_CART': {
      return initialState;
    }

    default:
      return draft;
  }
}

可以看到使用 useImmerReducer 後,我們可以直接在 reducer 中修改 draft 狀態,而不需要手動回傳新的狀態物件。

注意事項

  1. 不要直接修改 draft 以外的狀態值,immer 只能追蹤在 producer function 中的 draft 狀態變更。
  2. 回傳值處理:在 producer function 中,如果你回傳一個新的值,immer 會忽略對 draft 的修改,並使用你回傳的值作為新的狀態。

總結

useImmer 提供了一個簡潔且直觀的方式來管理 React 中的不可變狀態,特別是在處理巢狀物件或陣列時,大幅降低了心智負擔。

參考資料