Skip to content

Instantly share code, notes, and snippets.

@weilueluo
Last active April 12, 2023 00:45
Show Gist options
  • Save weilueluo/d7855462ff50d58de8fd0fa7929dab24 to your computer and use it in GitHub Desktop.
Save weilueluo/d7855462ff50d58de8fd0fa7929dab24 to your computer and use it in GitHub Desktop.
Simple Theme Management for React

Simple React Theme Management

  • Single themes.tsx file, ~200 lines.
  • Flashless (with ssr).
  • Typescript.

themes.tsx

import React, { useContext, useState } from "react";

// utils
export function cookieToObj(cookie: string | undefined): Record<string, string> {
    if (cookie) {
        return cookie.split("; ").reduce((obj: Record<string, string>, pair) => {
            const [k, v] = pair.split("=");
            obj[k] = v;
            return obj;
        }, {});
    }
    return {};
}

// types
export type ResolvedTheme = "light" | "dark";
export type UnResolvedTheme = ResolvedTheme | "system";

export interface UseTheme {
    unResolvedTheme: UnResolvedTheme;
    resolvedTheme: ResolvedTheme;
    setTheme: (theme: UnResolvedTheme) => void;
    nextTheme: () => UnResolvedTheme;
}

// constants
const THEME_KEY = "x-theme"; // for local storage and cookie
const DEFAULT_RESOLVED_THEME = "dark";
export const THEMES: ("light" | "dark" | "system")[] = ["system", "light", "dark"];
const ThemeContext = React.createContext<UseTheme>({
    resolvedTheme: DEFAULT_RESOLVED_THEME,
    unResolvedTheme: DEFAULT_RESOLVED_THEME,
    setTheme: () => {},
    nextTheme: () => DEFAULT_RESOLVED_THEME,
});

// functions to export
export default function ThemeProvider({
    children,
    cookies,
    defaultTheme = undefined,
}: {
    children: React.ReactNode;
    cookies: string | undefined;
    defaultTheme?: UnResolvedTheme;
}) {
    // cookies theme > local storage theme > provided default theme > builtin default theme
    const initialTheme =
        getCookiesTheme(cookies) ||
        getLocalStorageTheme() ||
        defaultTheme ||
        DEFAULT_RESOLVED_THEME;

    const systemTheme = useSystemTheme();
    const [unResolvedTheme, setUnResolvedTheme] = useState<UnResolvedTheme>(initialTheme);
    const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
        resolve(unResolvedTheme, systemTheme),
    );

    useEffect(() => {
        if (unResolvedTheme === "system") {
            setResolvedTheme(systemTheme);
        } else {
            setResolvedTheme(resolve(unResolvedTheme, systemTheme));
        }
    }, [systemTheme, unResolvedTheme]);

    useEffect(() => {
        if (typeof document !== 'undefined') {
            const bodyClassList = document.querySelector("body")?.classList;
            THEMES.forEach(theme => bodyClassList?.remove(theme)); // remove old theme
            document.querySelector("body")?.classList.add(resolvedTheme); // add new theme
        }
    }, [resolvedTheme])

    const setTheme = (newTheme: UnResolvedTheme) => {
        setUnResolvedTheme(newTheme);
        setClientSideCookieTheme(newTheme); // we need cookie so that server can prerender correct page to avoid flashing
        setLocalStorageTheme(newTheme); // we need local storage to persistent cookie long term, but it does not get send to server
    };

    const nextTheme = () => THEMES[(THEMES.indexOf(unResolvedTheme) + 1) % THEMES.length];

    return (
        <ThemeContext.Provider value={{ resolvedTheme, unResolvedTheme, setTheme, nextTheme }}>
            {children}
        </ThemeContext.Provider>
    );
}

// convinent hook to check the current theme / change the theme
export function useTheme(): UseTheme {
    return useContext(ThemeContext);
}

// internal methods, for implementation
function resolve(unResolvedTheme: UnResolvedTheme, systemTheme: ResolvedTheme) {
    return unResolvedTheme === "system" ? systemTheme : unResolvedTheme;
}

function useSystemTheme(): ResolvedTheme {
    const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(getSystemTheme);

    // listen for system changes, so that page react when user system theme changes
    useEffectOnce(() => {
        const handleEvent = (event: MediaQueryListEvent) => {
            const newTheme = event.matches ? "light" : "dark";
            console.log(`MediaQueryListEvent=${event}`);

            setSystemTheme(newTheme);
        };
        if (typeof window !== "undefined" && window.matchMedia) {
            window
                .matchMedia("(prefers-color-scheme: light)")
                .addEventListener("change", handleEvent);

            return () =>
                window
                    .matchMedia("(prefers-color-scheme: light)")
                    .removeEventListener("change", handleEvent);
        }
    });

    return systemTheme;
}

function getCookiesTheme(cookies: string | undefined): Nullable<UnResolvedTheme> {
    return cookieToObj(cookies)[THEME_KEY] as Nullable<UnResolvedTheme>;
}

function setClientSideCookieTheme(theme: UnResolvedTheme, days = 0) {
    if (typeof document !== "undefined" && theme) {
        const cookieObj = cookieToObj(document.cookie);
        cookieObj[THEME_KEY] = theme;
        if (days) {
            var date = new Date();
            date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
            document.cookie = `expires=${date.toUTCString()}`;
        }

        document.cookie = `${THEME_KEY}=${theme}`;
    }
}

function getSystemTheme(): ResolvedTheme {
    if (typeof window !== "undefined" && window.matchMedia) {
        if (window.matchMedia("(prefers-color-scheme: light)").matches) {
            return "light";
        } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
            return "dark";
        }
    }
    return DEFAULT_RESOLVED_THEME;
}

function setLocalStorageTheme(theme: UnResolvedTheme) {
    if (typeof localStorage !== "undefined") {
        localStorage.setItem(THEME_KEY, theme);
    }
}

function getLocalStorageTheme(): Nullable<UnResolvedTheme> {
    if (typeof localStorage !== "undefined") {
        const theme = localStorage.getItem(THEME_KEY);
        if (theme !== null) {
            return theme as UnResolvedTheme;
        }
    }
    return undefined;
}

function removeClientSideCookieTheme() {
    if (typeof document !== "undefined") {
        const cookieObj = cookieToObj(document.cookie);
        delete cookieObj[THEME_KEY];
    }
}

function removeLocalStorageTheme(): void {
    if (typeof localStorage !== "undefined") {
        localStorage.removeItem(THEME_KEY);
    }
}

How to use with NextJs

_app.tsx

import ThemeProvider from "path/to/themes.tsx";
import "path/to/globals.css";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
    return (
        <ThemeProvider cookies={pageProps.cookies}>
            <Component {...pageProps} />
        </ThemeProvider>
    );
}

page.tsx

// ... your other code
// https://nextjs.org/docs/api-reference/data-fetching/get-server-side-props
export function getServerSideProps({ req }: { req: IncomingMessage }) {
    // set the cookie such that we can pre-render page with correct theme
    return {
        props: { cookies: req?.headers?.cookie },
    };
}

Example: Button that changes theme

export default function Theme() {
    const { resolvedTheme, setTheme, unResolvedTheme, nextTheme } = useTheme();

    const onClick = () => {
        setTheme(nextTheme());
    };
    return (
        <button onClick={onClick}>
            Theme: {unResolvedTheme} {unResolvedTheme === "system" && resolvedTheme}
        </button>
    );
}

How to use with Tailwind CSS

tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class',
  // ... other settings
}

page.tsx

export default function Page() {
    const { resolvedTheme } = useTheme();
    return (
        <main className={resolvedTheme}>
            {//... other components}
        </main>
    );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment