next-themes vs localStorage – dlaczego nie warto wynajdywać koła na nowo 🎨

Zdarzyło Wam się kiedyś napisać własną logikę do dark mode? Bo mi tak. I powiem szczerze — efekt nie był taki, jakiego się spodziewałam.

Na początek — co jest nie tak z localStorage?

Wydawałoby się, że temat jest prosty. Zapisujecie preferencję użytkownika w localStorage, odczytujecie ją przy starcie i ustawiacie odpowiednią klasę na <html>. Koniec. Parę linii kodu i mamy dark mode.

W czystym React renderowanym po stronie klienta (CSR) to faktycznie działa. Problem zaczyna się w momencie, kiedy wchodzi server-side rendering — nieważne czy to Next.js, Remix, Astro z wyspami React, czy jakikolwiek inny framework z SSR.

Serwer nie ma dostępu do localStorage. Nie wie, czy użytkownik wolał ciemny czy jasny motyw. Więc renderuje domyślny — najczęściej jasny. Dopiero po załadowaniu JavaScriptu w przeglądarce odczytujemy wartość z localStorage i podmieniamy motyw. I co się wtedy dzieje?

Flash. Biały ekran miga na ułamek sekundy zanim podmieni się na ciemny. Użytkownik to widzi i wygląda to po prostu źle.

Własna implementacja — jak to zwykle wygląda

Jeśli robimy to ręcznie, potrzebujemy mniej więcej czegoś takiego:

import { useEffect, useState } from 'react'

export function useTheme() {
  const [theme, setTheme] = useState('light')

  useEffect(() => {
    const saved = localStorage.getItem('theme')
    if (saved) {
      setTheme(saved)
    } else {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
      setTheme(prefersDark ? 'dark' : 'light')
    }
  }, [])

  useEffect(() => {
    document.documentElement.classList.toggle('dark', theme === 'dark')
    localStorage.setItem('theme', theme)
  }, [theme])

  return { theme, setTheme }
}

Na pierwszy rzut oka? Wygląda ok. Ale lista problemów, które z tego wynikają, jest dłuższa niż mogłoby się wydawać. Flash dotyczy głównie SSR, ale reszta problemów pojawia się niezależnie od tego, czy macie server-side rendering, czy nie:

  • Flash of unstyled content — w projektach z SSR serwer renderuje jasny motyw, JS podmienia na ciemny po załadowaniu. Miga. Nawet w CSR bywa widoczny krótki “przeskok” zanim useEffect zdąży ustawić klasę.
  • Hydration mismatch — jeśli macie SSR, React widzi, że HTML z serwera nie zgadza się z tym, co generuje klient. Leci warning, a w najgorszym przypadku — dziwne zachowania UI.
  • System preference — trzeba samemu obsłużyć prefers-color-scheme, nasłuchiwać zmian (bo użytkownik może zmienić preferencje systemowe w trakcie sesji) i synchronizować to ze stanem.
  • Wielu motywów — jeśli kiedyś chcemy dodać np. motyw “sepia” albo “high contrast”, cała nasza logika wymaga przebudowy.
  • Forced theme na podstronach — np. strona logowania zawsze w jasnym motywie? Ręcznie? Powodzenia.

next-themes — dwie linijki i po sprawie

Nazwa może sugerować, że to biblioteka tylko do Next.js, ale to nieprawda. next-themes działa z dowolnym projektem React — Vite, Remix, Gatsby, Astro z React, cokolwiek. Trzeba tylko owinąć aplikację w ThemeProvider.

A teraz zobaczmy, jak to wygląda w praktyce:

pnpm add next-themes

Przykład z Next.js (layout.tsx):

import { ThemeProvider } from 'next-themes'

export default function RootLayout({ children }) {
  return (
    <html suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}

A w projekcie Vite/React? Praktycznie to samo, tylko w main.tsx:

import { ThemeProvider } from 'next-themes'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
    <App />
  </ThemeProvider>
)

I tyle. Serio.

A w komponencie do przełączania:

import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false)
  const { resolvedTheme, setTheme } = useTheme()

  useEffect(() => setMounted(true), [])
  if (!mounted) return null

  return (
    <button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
      {resolvedTheme === 'dark' ? '☀️' : '🌙'}
    </button>
  )
}
  • Dlaczego resolvedTheme zamiast theme? Bo jeśli użytkownik ma ustawiony motyw system, to theme zwróci "system" — a to nie mówi nam, czy ekran jest jasny czy ciemny. resolvedTheme zawsze zwraca finalną wartość: "light" albo "dark".
  • Dlaczego sprawdzam mounted? Bo przy SSR useTheme zwraca undefined — nie mamy dostępu do localStorage na serwerze. Gdybyśmy od razu wyrenderowali przycisk z wartością motywu, dostalibyśmy hydration mismatch. W projektach CSR (np. Vite) ten pattern nie jest konieczny, ale i tak nie zaszkodzi — dzięki niemu komponent jest przenośny między projektami z SSR i bez.

Jak next-themes rozwiązuje problem flasha?

ThemeProvider wstrzykuje inline’owy <script> do <head> strony, który wykonuje się przed wyrenderowaniem reszty dokumentu. Ten skrypt odczytuje localStorage (albo system preference przez matchMedia) i ustawia odpowiedni atrybut na <html>.

W teorii — skoro skrypt jest blokujący — przeglądarka nie powinna niczego narysować zanim go wykona. W praktyce flash jest znacząco zredukowany, ale nie zawsze wyeliminowany w 100%. Przy pierwszej wizycie (pusty localStorage, motyw systemowy dark) na niektórych urządzeniach i konfiguracjach wciąż można zobaczyć krótki przebłysk jasnego tła, zanim skrypt zdąży ustawić klasę.

Gdzie next-themes działa najlepiej:

  • Kolejne wizyty — preferencja jest już w localStorage, skrypt ustawia klasę natychmiast. Flash praktycznie nie występuje.
  • SSR z dynamicznym renderowaniem — serwer generuje HTML, skrypt w <head> ustawia motyw przed renderem <body>.

Gdzie flash wciąż może być widoczny:

  • Pierwsza wizyta z ciemnym motywem systemowym — HTML jest wyrenderowany jako jasny, skrypt musi nadrobić zaległość.
  • Statyczny eksport (output: 'export') — HTML jest generowany raz, bez wiedzy o preferencjach użytkownika.
  • Wolne urządzenia / cold load — przeglądarka może zdążyć narysować pierwszy frame zanim skrypt się wykona.

To wciąż ogromna poprawa w porównaniu z ręcznym useEffect, który wykonuje się po pierwszym renderze i praktycznie gwarantuje widoczny flash. Blokujący skrypt w <head> to najlepsza dostępna opcja po stronie klienta — ale nie jest idealna.

Co jeszcze daje next-themes?

Poza brakiem flasha, biblioteka obsługuje kilka rzeczy, które przy ręcznej implementacji wymagałyby sporo dodatkowego kodu:

  • Tryb system — automatycznie śledzi prefers-color-scheme i reaguje na zmiany w czasie rzeczywistym. Nie trzeba samemu dodawać matchMedia listenera.
  • Forced theme — można wymusić konkretny motyw na wybranej podstronie. Przydatne np. dla landing page’a, który zawsze ma być jasny.
  • Wiele motywów — nie jesteście ograniczeni do light/dark. Można zdefiniować themes={['light', 'dark', 'sepia', 'ocean']} i next-themes ogarnie resztę.
  • disableTransitionOnChange — wyłącza tymczasowo wszystkie CSS transitions przy zmianie motywu, żeby UI nie “mrugał” elementami z różnymi transition-duration.
  • Atrybut class lub data-theme — wybieracie, czy motyw ma być dodawany jako klasa CSS (idealne dla Tailwinda) czy jako data-attribute.

Hydration mismatch — dlaczego to jest ważniejsze niż myślisz

Hydration mismatch to nie tylko brzydki warning w konsoli. W React 18+ niezgodność między HTML-em z serwera a tym, co generuje klient, może prowadzić do realnych bugów — np. niepoprawnego stanu komponentów, “znikających” elementów UI albo podwójnego renderowania.

Przy ręcznym dark mode problem wygląda tak: serwer renderuje <html class=""> (bo nie wie, jaki motyw wybrał użytkownik), a klient po hydration ustawia <html class="dark">. React to widzi i protestuje.

next-themes obchodzi to w sprytny sposób — suppressHydrationWarning na tagu <html> mówi Reactowi, żeby zignorował różnicę w tym jednym miejscu. A inline script zaaplikowany przed renderem sprawia, że w praktyce ta różnica nawet nie powstaje, bo atrybut jest już ustawiony zanim React w ogóle zacznie hydrację.

A co z cookies zamiast localStorage?

Widziałam artykuły, które proponują alternatywne podejście — zamiast localStorage, trzymanie preferencji motywu w cookies. Dzięki temu serwer ma dostęp do preferencji użytkownika przy każdym requeście i może od razu wyrenderować właściwy motyw.

To rozwiązanie ma sens w projektach z SSR i faktycznie eliminuje problem flasha. Ale wymaga więcej kodu po stronie serwera — odczytywanie cookies w layoucie, ustawianie cookies po zmianie motywu przez API route albo Server Action. To zdecydowanie bardziej skomplikowane niż pnpm add next-themes. A w projektach CSR cookies w ogóle nie mają sensu, bo nie ma serwera który by je odczytywał przy renderowaniu.

Jeśli z jakiegoś powodu nie chcecie dodawać biblioteki — np. macie bardzo restrykcyjne wymagania dotyczące bundle size albo politykę “zero dependencies” — to cookies (przy SSR) to sensowna alternatywa. Ale w większości projektów next-themes będzie prostszym i szybszym wyborem.

Kiedy localStorage wystarczy?

Żeby nie było, że demonizuję localStorage. Jeśli budujecie:

  • bardzo prostą stronę, gdzie jednorazowy “przeskok” motywu nie jest problemem
  • prototyp albo wewnętrzne narzędzie, gdzie UX nie musi być dopieszczony
  • coś, co nie potrzebuje system preference ani wielu motywów

…to ręczne localStorage jest absolutnie w porządku. Nie każdy projekt potrzebuje next-themes.

Ale jeśli zależy Wam na tym, żeby dark mode działał dobrze — z minimalnym flashem, obsługą system preference, z możliwością łatwego rozszerzenia — to next-themes oszczędzi Wam mnóstwo czasu i nerwów. I to niezależnie od tego, czy Wasz projekt to Next.js, Vite, Remix czy cokolwiek innego.

Podsumowanie

localStorage ręcznienext-themes
Flash (SSR)Występuje zawszeZnacząco zredukowany (inline script)
Flash (CSR)Możliwy “przeskok”Znacząco zredukowany
Flash — pierwsza wizyta, system darkBrak (ale motyw jest zły — zawsze light)Minimalny flash możliwy
Flash — kolejne wizytyWystępujePraktycznie brak
Hydration mismatchTrzeba obsłużyć samemuObsłużony out of the box
System preferenceTrzeba samemu nasłuchiwaćWbudowane
Wiele motywówTrzeba przebudować logikęthemes prop
Forced theme na podstronieRęczna implementacjaforcedTheme prop
Działa z Next.js
Działa z Vite/Remix/inne
Rozmiar0 KB~2 KB (gzip)

Czy 2 KB dodatkowej zależności to dużo? Moim zdaniem — zdecydowanie mniej niż godziny ręcznego debugowania flasha, hydration mismatchów i edge case’ów z system preference.

You might also like