iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 22 篇

Day 22 : 查詢天氣,用 Effect 同步資料與清理

  • 分享至 

  • xImage
  •  

因為天氣好,因為天氣不好,因為天氣剛剛好

今天目標

  1. 認識 Effect 與 useEffect
  2. 比較畫面計算、事件處理與 Effect
  3. 城市天氣看板專案
  4. 測試執行
  5. 加入攝氏/華氏切換
  6. 錯誤修正練習

來到了 effect,順便換個專案口味吧,建立一個新的「城市天氣看板」專案。

認識 Effect

在前面,我們提到 side effect(副作用)指除了計算並回傳結果,還會改變外部狀態或與外部系統互動的操作。

雖然「副作用」描述的是計算結果以外的影響,但有時候這類操作本身是提供正常的功能,例如發送 API 請求、操作 DOM、寫入瀏覽器儲存空間。

React 的 Effect 就是副作用的一種,由元件渲染引起,會在提交畫面後執行,元件渲染時依目前的 props 與 state 描述畫面,當 React 提交畫面後,Effect 再讓外部工作配合目前資料。

以接著要做的天氣看板為例,當城市選擇高雄,就要查高雄的天氣;切換城市或關閉看板時,也要停止已不需要的查詢。

React:Effects 與事件

useEffect 寫法

呼叫 useEffect 時,把要執行的函式傳入,以及這份工作讀取的依賴:

useEffect(() => {
  // setup:依目前城市開始查詢

  return () => {
    // cleanup:停止這一輪工作
  };
}, [cityId]);
片段 意思
useEffect(...) 宣告這個元件需要執行的外部同步工作
第一個 () => { ... } 傳給 React 的 setup 函式,包含開始同步的程式
return () => { ... } setup 回傳這一輪的 cleanup 函式,交給 React 在清理時呼叫
[cityId] 依賴陣列,列出這份工作使用的城市值

setup 與 cleanup 是描述用途的稱呼,由於 useEffect 本身會回傳 undefined,所以這裡不需要像昨天的 useRef 一樣,用變數接住一個物件。

React:useEffect parameters and returns

執行時機

useEffect 的使用和其他 Hook 一樣,在元件最上層呼叫。

React 渲染元件時會先取得 JSX,提交到畫面後再執行需要同步的 setup,把函式傳進 Hook。

以天氣看板來說,初次提交時先顯示載入提示,Effect 才開始取得天氣。收到資料後呼叫 state setter,React 再更新結果,Effect 只在瀏覽器端執行,所以伺服器渲染的初始畫面也是載入提示。

React:useEffect

渲染元件、取得 JSX -> 提交畫面 -> setup 開始查詢
  -> 收到資料、更新 state -> 再次渲染與提交結果

依賴與同步

[cityId] 放目前依賴的值,React 會使用 Object.is 比較前後的每個依賴。

假設目前是台北,在 Effect 讀取的 cityId 是 "taipei",若使用者改選高雄後,外層更新 state,內層會收到 "kaohsiung"。

React 提交新畫面時會比較依賴陣列中的值,當城市不同就需要重新同步。

Effect 裡讀取的 props、state 會隨渲染改變的值會對應到依賴,例如後面加入溫度單位後,查詢同時使用城市與單位,依賴也會成為 [cityId, temperatureUnit]。

依賴寫法 setup 執行時機
省略第二個參數 初次提交,以及這個元件後續每次提交後
[] 初次掛載後同步,不因 props 或 state 的更新重新同步
[cityId] 初次掛載後同步,之後城市值改變才重新同步

Effect 沒有隨渲染改變的資料時,可以使用空陣列,例如查詢要使用城市,就保留城市依賴。

元件移除再掛載會重新開始,StrictMode 在開發模式會額外檢查,因此 [] 在整個程式不會永遠只執行一次。

React:Specifying reactive dependencies

清理函式

當城市依賴改變,React 會呼叫上一輪的 cleanup,再執行新城市的 setup。

setup 可以回傳 cleanup,在示意片段中的 return () => { ... },就是把清理函式交給 React,並沒有在這一行執行呼叫。

看板元件從畫面移除時,也會呼叫最後一輪的 cleanup,每輪清理使用的是當次建立的資料,台北的清理不會拿高雄的新控制器來取消。

React:Connecting to an external system

初次顯示台北 -> 台北 setup
改成高雄    -> 台北 cleanup -> 高雄 setup
關閉看板    -> 高雄 cleanup

元件裡會看到兩種 return,用途不同:

段落 回傳內容 用途
Effect 的 setup 裡 cleanup 函式 交代如何停止或解除這輪工作
元件函式裡 JSX 描述這次畫面要顯示什麼

清理方式要依實際建立的外部工作處理,例如今天的天氣查詢會使用 AbortController 是瀏覽器提供的取消控制器,可以停止尚未完成的 fetch 請求。

像是台北的查詢還沒完成就改選高雄,cleanup 會用它取消台北的舊查詢,並檢查是否已取消,避免已清理的工作更新結果,再由新的 setup 開始高雄查詢。

方法 用途
new AbortController() 建立這一輪的取消控制器
controller.signal 傳給 fetch,讓請求接收取消通知
controller.abort() 在 cleanup 中呼叫,取消這輪尚未完成的請求

React 會負責呼叫 cleanup,裡面的 abort() 才是負責實際取消請求。

MDN:AbortController

渲染與同步

收到天氣後更新查詢 state,會讓內層元件重新渲染,輸入行程備註也可能讓外層與內層重新渲染,但重新渲染與重新同步是兩件事。

今天的依賴只有 [cityId],當城市沒有改變就不會因為這些更新並重新 setup,這讓查詢結果能更新畫面,備註能正常輸入,不必每次都取得新的天氣資料。

常見用途

Effect 通常是用來讓元件的資料或狀態配合 React 以外的系統:

用途 範例 配合處理的清理
取得外部資料 依城市向天氣 API 查詢 取消未完成的請求,或忽略已不需要的結果
建立外部事件訂閱 監聽瀏覽器視窗或第三方服務的事件 解除這輪建立的監聽或訂閱
同步第三方工具 讓地圖或播放器配合目前設定 依工具 API 解除訂閱或釋放建立的資源

回到天氣看板,天氣查詢需要配合所選城市,所以使用 Effect。城市名稱與備註摘要則直接從現有資料計算,不需要 Effect。

React:Synchronizing with Effects

了解 Effect 後,接著比較各元件應放的內容與位置。

比較畫面計算、事件處理與 Effect

動作說明

動作 位置 範例
依目前資料計算畫面 渲染時的純計算 找出城市名稱、整理時間文字
回應操作 事件處理函式 選擇城市、輸入備註、開關看板
隨目前資料與元件存在情況同步外部系統 Effect 依城市查詢 API,停止不需要的查詢

城市名稱來自固定清單,渲染時直接找出即可,選單中的 onChange 回應使用者操作,把新城市交給 state setter,當需要與外部 API 同步的查詢,再由內層元件的 Effect 處理。

選單與查詢

選單事件只更新城市 state,查詢資料會隨著 WeatherPanel 的城市 prop 同步,即使城市將來由網址或其他元件改變,內層元件也能依目前資料查詢。

昨天的聚焦按鈕只要回應那次點擊,所以在事件中呼叫 focus(),今天的天氣區塊需要配合目前城市與元件是否存在,才會把開始與停止查詢交給 Effect。

React:Fetching data with Effects

城市天氣看板專案

專案建立的方式就參考前面兩次的指令吧,命名為 weather-dashboard。

建立一個 practice 目錄在 app 底下:

檔案 用途
app/practice/weather.ts 城市清單、查詢網址、回傳格式檢查
app/practice/weather-dashboard.tsx 外層選擇設定、內層查詢與清理

也記得修改 app/routes/home.tsx,把 <WeatherDashboard /> 也加入。

天氣 API

首先是能取得天氣資訊的 API,這次使用的 Open-Meteo Forecast API,是非商業可用公開端點,不需 API key。

建立一份固定清單城市座標,把台北、台中、高雄放進去。

接著是在 weather.ts 建立查詢方法 buildWeatherUrl,主要參數如下:

參數 設定
latitude、longitude 所選城市的查詢座標
current temperature_2m,wind_speed_10m
temperature_unit 基礎版固定 celsius
wind_speed_unit kmh
timezone Asia/Taipei

回應會包含 current 數值、時間以及 current_units 的單位。

再建立一個 readWeatherData 檢查必要欄位後轉成畫面資料,因為 response.json() 不會自動驗證格式,TypeScript 型別也不能代替實際檢查。

Open-Meteo:Current Weather

程式片段

外層元件

const [cityId, setCityId] = useState<CityId>("taipei");
const [showWeather, setShowWeather] = useState(true);
const [note, setNote] = useState("");

在外層元件 WeatherDashboard,分別用 state 保存城市、看板開關與備註。
在內層元件WeatherPanel,分別對應,接收城市、保存查詢結果。

查詢流程

整個動作會是:

城市選單事件 -> 更新 cityId -> 傳入新的 prop
  -> 清理上一輪 Effect -> 新 setup 建立控制器
  -> fetch -> 檢查 HTTP 狀態 -> 讀取 JSON
  -> 更新 success 或 error -> 顯示結果

現在來看 fetch 到顯示結果前的片段:

// 示意片段
async function loadWeather() {
  try {
    const response = await fetch(buildWeatherUrl(cityId), {
      signal: controller.signal,
    });
    if (!response.ok) {
      throw new Error(`天氣查詢失敗(HTTP ${response.status}),請稍後再試。`);
    }

    const json: unknown = await response.json();
    if (controller.signal.aborted) return;

    const data = readWeatherData(json);
    setWeather({ status: "success", requestKey, data });
    console.log("[天氣 success]", requestKey);
  } catch (error) {
    if (controller.signal.aborted) return;

    setWeather({
      status: "error",
      requestKey,
      errorMessage: error instanceof Error ? error.message : "暫時無法取得天氣,請稍後再試。",
    });
    console.log("[天氣 error]", requestKey);
  }
}

void loadWeather();
  • 整個 loadWeather 是 Effect 裡的非同步函式,setup 本身是普通函式,只需要回傳 cleanup 就好,不能用 useEffect(async () => ...),這樣會回傳 Promise。

  • await 等待 fetch 回應,但因為 HTTP 404 或 500 不會讓 fetch 進 catch,所以 response.ok 檢查沒問題之後再用 response.json() 讀內容。

  • weather.status 區分 loading、success、error,結果附帶 requestKey 並以城市 ID 識別,渲染只呈現目前查詢的資料。當城市變動且下一輪 Effect 還沒設定 loading 時,不會把上個城市的氣溫掛在新標題下。

MDN:Using the Fetch API

請求清理

前面提到 cleanup 會呼叫 abort(),取消還在等待的請求與回應內容讀取,因為已取消的 signal 不能重用到下一輪,我們每一輪 setup 都要建立新的 AbortController,讓 loadWeather() 裡的 fetch 使用當次建立的控制器 signal:

// 示意片段
const controller = new AbortController();
// 在 loadWeather() 裡
const response = await fetch(buildWeatherUrl(cityId), {
  signal: controller.signal,
});

loadWeather() 的成功處理與 catch 分支,都會先執行這個判斷:

if (controller.signal.aborted) return;

已清理的工作會直接結束,不再寫入結果,也不會把主動取消顯示成失敗,因為取消不保證會撤回對查詢天氣 API 伺服器已收到的請求,這樣也確保後續的 state 更新不會被取消的查詢覆蓋。

MDN:AbortController

清理函式交給 React,在這輪依賴改變或元件移除時呼叫:

// 示意片段
return () => {
  controller.abort();
  console.log("[天氣 cleanup]", requestKey);
};

控制器與 cleanup 在同一次的 setup 函式中建立,cleanup 能透過 closure 取得當輪控制器的參照並執行取消請求。

測試執行

連線開啟看板

https://ithelp.ithome.com.tw/upload/images/20261006/20184332gx2a4f3a0b.png

正常連線下應該會取得台北的氣象資料。

切成 Offline,關閉再顯示

https://ithelp.ithome.com.tw/upload/images/20261006/20184332fgzEgu9rXA.png

慢速連線下,請求完成前改選台中

https://ithelp.ithome.com.tw/upload/images/20261006/20184332rADC4AqAY8.png

從 console 可以觀察到還沒載入前,切換城市會直接 cleanup 之前的選擇。

慢速連線下,請求完成前取消顯示

說明一下,在資料載入完成之前取消,console 內會直接出現 cleanup 的事件,cleanup 紀錄表示清理函式已執行。

所以畫面上會是看板已關閉。
https://ithelp.ithome.com.tw/upload/images/20261006/20184332I6WJXndudz.png

修改溫度單位

新增溫度設定

需求改成:「城市保持不變,能切換攝氏/華氏取得對應單位資料。」

首先在 weather-dashboard.tsx 從 ./weather 的 import 新增:

type TemperatureUnit,

在 WeatherDashboard 的 cityId Hook 下一行新增:

const [temperatureUnit, setTemperatureUnit] = useState<TemperatureUnit>("celsius");

在「查詢設定」的城市 <div> 後,顯示勾選框前新增:

// 示意片段
<div>
  <label htmlFor="temperature-unit">溫度單位</label>
  <select
    id="temperature-unit"
    value={temperatureUnit}
    onChange={(event) => setTemperatureUnit(event.target.value as TemperatureUnit)}
  >
    <option value="celsius">攝氏 °C</option>
    <option value="fahrenheit">華氏 °F</option>
  </select>
</div>

選項固定兩個單位,值只會是型別列出的兩個字串,要注意 as 是指 TypeScript 的型別宣告。

更新查詢

將 <WeatherPanel cityId={cityId} /> 替換為:

<WeatherPanel cityId={cityId} temperatureUnit={temperatureUnit} />

把內層函式的開頭替換為:

function WeatherPanel({
  cityId,
  temperatureUnit,
}: {
  cityId: CityId;
  temperatureUnit: TemperatureUnit;
}) {

其他更換

位置 修改
Effect 中的 requestKey 改成包含城市與單位的字串,見下方片段
buildWeatherUrl(cityId) buildWeatherUrl(cityId, temperatureUnit)
Effect 最後的 [cityId] [cityId, temperatureUnit]
渲染區的 currentKey 改成相同格式的字串,見下方片段

以及兩個查詢身分的位置都要改:

// Effect 裡
const requestKey = `${cityId}:${temperatureUnit}`;

// 渲染區,Effect 外面
const currentKey = `${cityId}:${temperatureUnit}`;

因為 Effect 讀取溫度設定,需要把設定也列入依賴,切換時先 cleanup,再用新設定查詢,在請求身分上也包含單位,避免會短暫顯示上一次設定的結果。

畫面會直接使用 API 回傳的溫度單位和數值,風速固定 km/h,資料時間使用台灣時間。

Open-Meteo API 參數

測試觀察

台北由攝氏改華氏

https://ithelp.ithome.com.tw/upload/images/20261006/20184332kNKuX5wCGM.png

同一城市切回攝氏

https://ithelp.ithome.com.tw/upload/images/20261006/20184332fLoVmLe6Tl.png

錯誤修正練習

漏清理

如果沒有做清理:

return () => {
  //controller.abort();
  console.log("[天氣 cleanup]", requestKey);
};

一樣保留 signal、aborted 判斷與 Log,設定慢速連線後完整重新載入,在請求完成前關閉看板。

https://ithelp.ithome.com.tw/upload/images/20261006/201843326t3uJAq5kD.png

觀察到印出 cleanup,但舊請求繼續完成,之後還可能印出 success,表示區塊雖然移除,但外部工作沒有停止。

requestKey 可避免將不相符資料掛在目前城市下,卻不能取消網路工作,顯示資料的檢查與資源清理,各有各的責任。

還原 controller.abort(),完整重新載入,重做相同操作,等待中的舊請求會被取消。

cleanup 也依賴改變時執行,不只是離開網頁時,當停止這輪仍在進行的工作,不能撤回伺服器已完成的處理。


參考資料

附錄基礎版

以下是完整weather-dashboard.tsx。

import { useEffect, useState } from "react";
import {
  CITIES,
  buildWeatherUrl,
  getCity,
  readWeatherData,
  type CityId,
  type WeatherData,
} from "./weather";

type WeatherState = { requestKey: string } & (
  | { status: "loading" }
  | { status: "success"; data: WeatherData }
  | { status: "error"; errorMessage: string }
);

function WeatherPanel({ cityId }: { cityId: CityId }) {
  const [weather, setWeather] = useState<WeatherState>({
    status: "loading",
    requestKey: "",
  });

  useEffect(() => {
    const controller = new AbortController();
    const requestKey = cityId;
    setWeather({ status: "loading", requestKey });
    console.log("[天氣 setup]", requestKey);

    async function loadWeather() {
      try {
        const response = await fetch(buildWeatherUrl(cityId), {
          signal: controller.signal,
        });
        if (!response.ok) {
          throw new Error(`天氣查詢失敗(HTTP ${response.status}),請稍後再試。`);
        }

        const json: unknown = await response.json();
        if (controller.signal.aborted) return;

        const data = readWeatherData(json);
        setWeather({ status: "success", requestKey, data });
        console.log("[天氣 success]", requestKey);
      } catch (error) {
        if (controller.signal.aborted) return;

        setWeather({
          status: "error",
          requestKey,
          errorMessage: error instanceof Error ? error.message : "暫時無法取得天氣,請稍後再試。",
        });
        console.log("[天氣 error]", requestKey);
      }
    }

    void loadWeather();

    return () => {
      controller.abort();
      console.log("[天氣 cleanup]", requestKey);
    };
  }, [cityId]);

  const city = getCity(cityId);
  const currentKey = cityId;
  const visibleWeather: WeatherState = weather.requestKey === currentKey
    ? weather
    : { status: "loading", requestKey: currentKey };

  return (
    <section
      aria-labelledby="weather-heading"
      aria-busy={visibleWeather.status === "loading"}
    >
      <h2 id="weather-heading">{city.name}的天氣</h2>
      {visibleWeather.status === "loading" ? (
        <p role="status">正在載入{city.name}的天氣…</p>
      ) : null}
      {visibleWeather.status === "error" ? (
        <div role="alert">
          <p>{visibleWeather.errorMessage}</p>
          <p>請檢查連線,稍後切換城市或重新顯示天氣看板。</p>
        </div>
      ) : null}
      {visibleWeather.status === "success" ? (
        <dl>
          <div>
            <dt>氣溫</dt>
            <dd>{visibleWeather.data.temperature} {visibleWeather.data.temperatureUnit}</dd>
          </div>
          <div>
            <dt>風速</dt>
            <dd>{visibleWeather.data.windSpeed} {visibleWeather.data.windSpeedUnit}</dd>
          </div>
          <div>
            <dt>資料時間(台灣時間)</dt>
            <dd>{visibleWeather.data.time.replace("T", " ")}</dd>
          </div>
        </dl>
      ) : null}
    </section>
  );
}

export function WeatherDashboard() {
  const [cityId, setCityId] = useState<CityId>("taipei");
  const [showWeather, setShowWeather] = useState(true);
  const [note, setNote] = useState("");

  return (
    <main>
      <header>
        <p>第四階段 · DAY 22</p>
        <h1>城市天氣看板</h1>
        <p>選擇出發城市,查看氣溫、風速與資料時間。</p>
      </header>

      <section aria-label="查詢設定">
        <div>
          <label htmlFor="city">城市</label>
          <select
            id="city"
            value={cityId}
            onChange={(event) => setCityId(event.target.value as CityId)}
          >
            {CITIES.map((city) => (
              <option key={city.id} value={city.id}>{city.name}</option>
            ))}
          </select>
        </div>
        <label>
          <input
            type="checkbox"
            checked={showWeather}
            onChange={(event) => setShowWeather(event.target.checked)}
          />
          顯示天氣看板
        </label>
      </section>

      {showWeather ? (
        <WeatherPanel cityId={cityId} />
      ) : <p>天氣看板已關閉。</p>}

      <section aria-label="行程備註">
        <p><label htmlFor="trip-note">行程備註</label></p>
        <textarea
          id="trip-note"
          rows={3}
          value={note}
          onChange={(event) => setNote(event.target.value)}
          placeholder="例如:下午出門,記得帶傘"
        />
        <p>備註:{note || "尚未填寫"}</p>
      </section>

      <footer>
        <p>天氣資料由 <a href="https://open-meteo.com/">Open-Meteo</a> 提供。</p>
        <p>目前天氣依據氣象模型資料,時間為資料時間。</p>
      </footer>
    </main>
  );
}

用來做資料處理的 weather.tsx:

export const CITIES = [
  { id: "taipei", name: "台北", latitude: 25.033, longitude: 121.5654 },
  { id: "taichung", name: "台中", latitude: 24.1477, longitude: 120.6736 },
  { id: "kaohsiung", name: "高雄", latitude: 22.6273, longitude: 120.3014 },
] as const;

export type CityId = (typeof CITIES)[number]["id"];
export type TemperatureUnit = "celsius" | "fahrenheit";

export type WeatherData = {
  temperature: number;
  temperatureUnit: string;
  windSpeed: number;
  windSpeedUnit: string;
  time: string;
};

export function getCity(cityId: CityId) {
  return CITIES.find((city) => city.id === cityId) ?? CITIES[0];
}

export function buildWeatherUrl(
  cityId: CityId,
  temperatureUnit: TemperatureUnit = "celsius",
) {
  const city = getCity(cityId);
  const params = new URLSearchParams({
    latitude: String(city.latitude),
    longitude: String(city.longitude),
    current: "temperature_2m,wind_speed_10m",
    temperature_unit: temperatureUnit,
    wind_speed_unit: "kmh",
    timezone: "Asia/Taipei",
  });

  return `https://api.open-meteo.com/v1/forecast?${params}`;
}

export function readWeatherData(value: unknown): WeatherData {
  const data = value as {
    current?: {
      temperature_2m?: unknown;
      wind_speed_10m?: unknown;
      time?: unknown;
    };
    current_units?: {
      temperature_2m?: unknown;
      wind_speed_10m?: unknown;
    };
  } | null;
  const current = data?.current;
  const units = data?.current_units;

  if (
    typeof current?.temperature_2m !== "number" ||
    !Number.isFinite(current.temperature_2m) ||
    typeof current.wind_speed_10m !== "number" ||
    !Number.isFinite(current.wind_speed_10m) ||
    typeof current.time !== "string" ||
    typeof units?.temperature_2m !== "string" ||
    typeof units.wind_speed_10m !== "string"
  ) {
    throw new Error("天氣資料格式不符合預期,請稍後重新查詢。");
  }

  return {
    temperature: current.temperature_2m,
    temperatureUnit: units.temperature_2m,
    windSpeed: current.wind_speed_10m,
    windSpeedUnit: units.wind_speed_10m,
    time: current.time,
  };
}

上一篇
Day 21 : 聚光燈輪到你,用 ref 操作 DOM
下一篇
Day 23 : 搭波康鳳,Effect 的依賴與重試
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言