React 專案實作 Dark Mode:CSS Variables 與 State 管理技巧

更新 發佈閱讀 9 分鐘

前言

深色模式 (Dark Mode) 可以說是現代網站的基本功能之一,不只是美觀,更能提升使用者在不同環境下的閱讀體驗 (我本人也是深色模式愛好者)。

在這篇文章中,我會分享我在 React 專案中實作 Dark Mode 的方法 ~

(本專案使用 Vite + React,因此不考慮 SSR)


設計目標

開始實作前,定義了幾個核心需求:

  • 畫面依照深/淺色切換樣式
  • 網站要能夠記住使用者偏好

要實現這樣的功能,需要完成這三件事:

1. 設計深/淺色對應的 CSS

2. 建立切換按鈕

3. 儲存偏好設定


Step 1: 用 CSS Variables 定義主題

如果為了深色模式重新寫一大堆 background-colorcolor 不太方便,更有效的方式是利用 CSS Variable

在 CSS 中,我們可以定義變數:

:root {

  /* 宣告變數 */
  --bg: #ffffff;
  --text: #111111;

}


/* 深色模式 */
[data-theme="dark"] {
  --bg: #111111;
  --text: #ffffff;
}

body {
  /* 使用變數 */
  background: var(--bg);
  color: var(--text);
}

Step 2: React 控制主題切換

以下為錯誤寫法,後續會說明原因與修正方式!!

2.1 使用 `useState` 儲存主題狀態

const [theme, setTheme] = useState("light");

2.2 使用 `useEffect []` 設定初始值

載入畫面時依照偏好設定深/淺色,如果沒有儲存的偏好設置則依照系統主題

  useEffect(() => {
    const savedTheme = localStorage.getItem("theme");

    if (savedTheme) {
      setTheme(savedTheme);
    } else {
      // 偵測系統主題
      const prefersDark = window.matchMedia(
        "(prefers-color-scheme: dark)"
      ).matches;

      setTheme(prefersDark ? "dark" : "light");
    }
  }, []);

2.3 使用 `useEffect [theme]` 切換主題

用按鈕處切換 theme

<button onClick={() => settheme(theme==="light"?"dark" :"light")}>
  切換模式
</button>

當 theme 變更時,切換深淺色模式,並將主題存到 localstorage 裡

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
    localStorage.setItem("theme", theme);
  }, [theme]);

遇到的問題:刷新頁面主題設置失效

上方的程式碼其實有個小問題,會導致重新整理頁面時,主題設置不會被記住,永遠是淺色。

原因是 react 的 StrictMode 幫我抓到了我 useEffect 寫不好的地方。

React StrictMode 在開發環境下會「刻意重新執行部分 lifecycle (也就是 mount + unmount)」,用來幫助開發者發現副作用 (side effects)寫法的問題。

來看一下我的程式碼:

第一次掛載

1. 載入頁面時,會給 theme 設定初始值 "light"

const [theme, setTheme] = useState("light");

2. useEffect [] 觸發,讀取 localstorage 的內容得到 dark

useEffect(()=>{

    const savedTheme = localStorage.getItem("theme");

    if(savedTheme){
        settheme(savedTheme);

    }else{
        const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
        settheme(prefersDark? "dark": "light")
    }

},[]);

在這時,雖然在 savedTheme = localStorage.getItem("theme") 讀取到了 "dark",但是 setTheme 是非同步,將資料寫回這件事不會馬上發生,而是排隊等待執行。

3. useEffect [theme] 執行

useEffect(()=>{

    console.log("useEffect #2 執行,theme =", theme);

    document.documentElement.setAttribute("data-theme", theme);

    localStorage.setItem("theme", theme);

}, [theme]);

這個 useEffect 執行時 theme 還是 "light",所以 "light" 被寫回 localstorage。


第二次掛載

1. theme 又被初始為 "light"

const [theme, setTheme] = useState("light");

2. useEffect [] 再執行一次

第一次掛載時,localstorage 已經被寫成 "light",所以這時讀取到的 const savedTheme = localStorage.getItem("theme"); 會是 "light",導致第一次 useEffect [] 寫回的 setTheme(dark)setTheme(light) 蓋掉。

3. useEffect [theme] 抓到的又是 "light"

解法:使用 lazy initialization  

修改程式碼

const [theme, settheme] = useState(()=>{
    const savedTheme = localStorage.getItem("theme");

    if(savedTheme){
        return savedTheme;

    }else{
        const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
        return prefersDark? "dark": "light";
    }
})

在渲染過程中直接讀取 localstorage 作為初始值,這樣完全不需要 `useEffect []`,也不會有被覆蓋的問題。


總結

這次的問題其實可以總結成一個重要原則:

不要用 useEffect 初始化 state

原因是:

  • useEffect 是在 render 之後才執行
  • 中間可能發生資料覆蓋問題

正確做法應該是:

  • 初始化資料用 useState (lazy initialization)
  • 副作用同步才用 useEffect

這樣才能確保資料來源單一且穩定!!


要做一個切換深淺色主題的功能本身不會很難,但是在初始化的時候沒有處理好小細節,反而會出現意料外的錯誤行為~ 經過這個錯誤也讓我更加了解 useState、useEffect 等 hooks 的特性,也算是因禍得福~~

留言
avatar-img
Elaine 粼粼的林林總總
10會員
40內容數
不定期地分享程式/旅遊/學習/閱讀或各式各樣的文章,如果對我的分享有興趣,歡迎來找我玩~
2026/03/14
本篇文章介紹如何在 React 專案中使用 react-markdown 套件,將 Markdown 檔案輕鬆轉換為網頁內容。文章涵蓋了安裝套件、基本渲染以及進階的 fetch 預防解析失敗的技巧。
Thumbnail
2026/03/14
本篇文章介紹如何在 React 專案中使用 react-markdown 套件,將 Markdown 檔案輕鬆轉換為網頁內容。文章涵蓋了安裝套件、基本渲染以及進階的 fetch 預防解析失敗的技巧。
Thumbnail
2026/03/03
將 GitHub repo 的前端作品部署到了 vercel 卻反覆發生 404,記錄下 debug 的過程。
Thumbnail
2026/03/03
將 GitHub repo 的前端作品部署到了 vercel 卻反覆發生 404,記錄下 debug 的過程。
Thumbnail
2026/02/28
前一篇文章介紹了 JavaScript 中的 class 及語法,本篇文章將延續上一篇的基礎,進一步深入了解 extends 如何建立繼承關係、super() 在建構子中的角色、super.method() 如何搭配覆寫(override)使用。
2026/02/28
前一篇文章介紹了 JavaScript 中的 class 及語法,本篇文章將延續上一篇的基礎,進一步深入了解 extends 如何建立繼承關係、super() 在建構子中的角色、super.method() 如何搭配覆寫(override)使用。
看更多
你可能也想看
Thumbnail
這是一場修復文化與重建精神的儀式,觀眾不需要完全看懂《遊林驚夢:巧遇Hagay》,但你能感受心與土地團聚的渴望,也不急著在此處釐清或定義什麼,但你的在場感受,就是一條線索,關於如何找著自己的路徑、自己的聲音。
Thumbnail
這是一場修復文化與重建精神的儀式,觀眾不需要完全看懂《遊林驚夢:巧遇Hagay》,但你能感受心與土地團聚的渴望,也不急著在此處釐清或定義什麼,但你的在場感受,就是一條線索,關於如何找著自己的路徑、自己的聲音。
Thumbnail
這篇文章整理了前端開發中常見的效能優化技巧、React與JavaScript的知識點,以及Redux Toolkit和React Fiber的應用、Reflow與Repaint、Event Loop、Higher Order Component、React Hooks等主題。
Thumbnail
這篇文章整理了前端開發中常見的效能優化技巧、React與JavaScript的知識點,以及Redux Toolkit和React Fiber的應用、Reflow與Repaint、Event Loop、Higher Order Component、React Hooks等主題。
Thumbnail
本文分析導演巴里・柯斯基(Barrie Kosky)如何運用極簡的舞臺配置,將布萊希特(Bertolt Brecht)的「疏離效果」轉化為視覺奇觀與黑色幽默,探討《三便士歌劇》在當代劇場中的新詮釋,並藉由舞臺、燈光、服裝、音樂等多方面,分析該作如何在保留批判核心的同時,觸及觀眾的觀看位置與人性幽微。
Thumbnail
本文分析導演巴里・柯斯基(Barrie Kosky)如何運用極簡的舞臺配置,將布萊希特(Bertolt Brecht)的「疏離效果」轉化為視覺奇觀與黑色幽默,探討《三便士歌劇》在當代劇場中的新詮釋,並藉由舞臺、燈光、服裝、音樂等多方面,分析該作如何在保留批判核心的同時,觸及觀眾的觀看位置與人性幽微。
Thumbnail
在 2021 年的剛轉職成為前端工程師的時候,我在面試時滿常會被詢問到 JavaScript 中閉包的議題,當時候自己回答的滿差的,於是在 2022 年時,我寫了一系列的有關於函式程式設計鐵人賽的文章, 裡頭就有簡單提到有關於閉包的議題。
Thumbnail
在 2021 年的剛轉職成為前端工程師的時候,我在面試時滿常會被詢問到 JavaScript 中閉包的議題,當時候自己回答的滿差的,於是在 2022 年時,我寫了一系列的有關於函式程式設計鐵人賽的文章, 裡頭就有簡單提到有關於閉包的議題。
Thumbnail
Zustand是什麼?React前端狀態管理 分別講解狀態管理以及Zustand 是什麼?接續下來講解Zustand用法以及Context以及Redux的比較。
Thumbnail
Zustand是什麼?React前端狀態管理 分別講解狀態管理以及Zustand 是什麼?接續下來講解Zustand用法以及Context以及Redux的比較。
Thumbnail
在前端開發中,很常會有需要轉址的需求,且處理的手法滿因人而異的,所以今天就想要來整理一些常見的 JavaScript 頁面轉址方式,以及各自的差異。
Thumbnail
在前端開發中,很常會有需要轉址的需求,且處理的手法滿因人而異的,所以今天就想要來整理一些常見的 JavaScript 頁面轉址方式,以及各自的差異。
Thumbnail
這篇文章深入淺出地解釋 JavaScript 中表達式 (expression) 與陳述式 (statement) 的差異,並以 React 中 JSX 的應用為例,說明為何大括號 {} 內只能放入表達式。文章以類比人類語言的句子結構來幫助理解,並提供相關參考資料連結。
Thumbnail
這篇文章深入淺出地解釋 JavaScript 中表達式 (expression) 與陳述式 (statement) 的差異,並以 React 中 JSX 的應用為例,說明為何大括號 {} 內只能放入表達式。文章以類比人類語言的句子結構來幫助理解,並提供相關參考資料連結。
Thumbnail
5 月將於臺北表演藝術中心映演的「2026 北藝嚴選」《海妲・蓋柏樂》,由臺灣劇團「晃晃跨幅町」製作,本文將以從舞台符號、聲音與表演調度切入,討論海妲・蓋柏樂在父權社會結構下的困境,並結合榮格心理學與馮.法蘭茲對「阿尼姆斯」與「永恆少年」原型的分析,理解女人何以走向精神性的操控、毀滅與死亡。
Thumbnail
5 月將於臺北表演藝術中心映演的「2026 北藝嚴選」《海妲・蓋柏樂》,由臺灣劇團「晃晃跨幅町」製作,本文將以從舞台符號、聲音與表演調度切入,討論海妲・蓋柏樂在父權社會結構下的困境,並結合榮格心理學與馮.法蘭茲對「阿尼姆斯」與「永恆少年」原型的分析,理解女人何以走向精神性的操控、毀滅與死亡。
Thumbnail
這一集用最新的Vite工具去創建初始檔案。Vite用於創建和構建Web應用程序,具有快速的啟動時間、即時熱更新、小型體積、支持多種框架和可擴展性等優點。
Thumbnail
這一集用最新的Vite工具去創建初始檔案。Vite用於創建和構建Web應用程序,具有快速的啟動時間、即時熱更新、小型體積、支持多種框架和可擴展性等優點。
Thumbnail
本文章提供前端開發的完整知識地圖,涵蓋 JavaScript 基礎概念、進階概念、前端開發基礎、前端框架與工具、系統設計與架構,以及開發工具與實作等面向,並以 SEO 友善的方式撰寫,適合想學習前端開發或準備面試的讀者。
Thumbnail
本文章提供前端開發的完整知識地圖,涵蓋 JavaScript 基礎概念、進階概念、前端開發基礎、前端框架與工具、系統設計與架構,以及開發工具與實作等面向,並以 SEO 友善的方式撰寫,適合想學習前端開發或準備面試的讀者。
Thumbnail
自己在剛開始進入前端領域時,很剛好遇上需要使用 TypeScript 的案子,一開始都是跟著前輩怎麼寫就怎麼寫,不太有其他餘力來思考「為什麼」會需要寫這門程式語言,直到自己後來使用了 TypeScript 完整開發了電商的購物流程,才慢慢理解到使用 TypeScript 的好處與優勢。
Thumbnail
自己在剛開始進入前端領域時,很剛好遇上需要使用 TypeScript 的案子,一開始都是跟著前輩怎麼寫就怎麼寫,不太有其他餘力來思考「為什麼」會需要寫這門程式語言,直到自己後來使用了 TypeScript 完整開發了電商的購物流程,才慢慢理解到使用 TypeScript 的好處與優勢。
Thumbnail
背景:從冷門配角到市場主線,算力與電力被重新定價   小P從2008進入股市,每一個時期的投資亮點都不同,記得2009蘋果手機剛上市,當時蘋果只要在媒體上提到哪一間供應鏈,隔天股價就有驚人的表現,當時光學鏡頭非常熱門,因為手機第一次搭上鏡頭可以拍照,也造就傳統相機廠的殞落,如今手機已經全面普及,題
Thumbnail
背景:從冷門配角到市場主線,算力與電力被重新定價   小P從2008進入股市,每一個時期的投資亮點都不同,記得2009蘋果手機剛上市,當時蘋果只要在媒體上提到哪一間供應鏈,隔天股價就有驚人的表現,當時光學鏡頭非常熱門,因為手機第一次搭上鏡頭可以拍照,也造就傳統相機廠的殞落,如今手機已經全面普及,題
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News