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
useEffectzdąż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
resolvedThemezamiasttheme? Bo jeśli użytkownik ma ustawiony motywsystem, tothemezwróci"system"— a to nie mówi nam, czy ekran jest jasny czy ciemny.resolvedThemezawsze zwraca finalną wartość:"light"albo"dark".
- Dlaczego sprawdzam
mounted? Bo przy SSRuseThemezwracaundefined— nie mamy dostępu dolocalStoragena 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 śledziprefers-color-schemei reaguje na zmiany w czasie rzeczywistym. Nie trzeba samemu dodawaćmatchMedialistenera. - 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']}inext-themesogarnie resztę. disableTransitionOnChange— wyłącza tymczasowo wszystkie CSS transitions przy zmianie motywu, żeby UI nie “mrugał” elementami z różnymitransition-duration.- Atrybut
classlubdata-theme— wybieracie, czy motyw ma być dodawany jako klasa CSS (idealne dla Tailwinda) czy jakodata-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ęcznie | next-themes | |
|---|---|---|
| Flash (SSR) | Występuje zawsze | Znacząco zredukowany (inline script) |
| Flash (CSR) | Możliwy “przeskok” | Znacząco zredukowany |
| Flash — pierwsza wizyta, system dark | Brak (ale motyw jest zły — zawsze light) | Minimalny flash możliwy |
| Flash — kolejne wizyty | Występuje | Praktycznie brak |
| Hydration mismatch | Trzeba obsłużyć samemu | Obsłużony out of the box |
| System preference | Trzeba samemu nasłuchiwać | Wbudowane |
| Wiele motywów | Trzeba przebudować logikę | themes prop |
| Forced theme na podstronie | Ręczna implementacja | forcedTheme prop |
| Działa z Next.js | ✅ | ✅ |
| Działa z Vite/Remix/inne | ✅ | ✅ |
| Rozmiar | 0 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.



