The Tao of Light/Dark Mode

Rich BurkeRich Burke
8 min read

In every opposition between Yin and Yang, there's an opportunity for growth, learning, and balance.

It's safe to assume you're in one of two groups:

  1. You're here primarily to get a task done, secondarily to learn.

  2. You're here primarily to learn, secondarily to get a task done.

The framework we'll be using is React Router v7, which comes with Tailwind CSS. The sole library we'll be using is shadcn/ui; it includes Lucide React icons.

Group 1: tldr;

If you're in the first group, we want to get your task done as quickly as possible. Here's a summary of what needs to be done:

And here are the relevant files:

  • app/components/theme-switcher.tsx
  • app/routes/actions/set-theme.ts
  • app/routes.ts (particularly the action route)
  • app/.server/sessions/sessions.server.tsx
  • app/contexts/theme.context.tsx
  • app/root.tsx (particularly the loader(), Layout() and Document() functions)

Godspeed!

Group 2: Detailed walk-through

As we walk through the setup, I'll include links to additional reference reading. Those references look like this: (* React Router).

The setup

npx create-react-router@latest rrv7-ui-mode

Choose "Yes" when asked to install dependencies. Then...

cd rrv7-ui-mode
npx shadcn@latest init
npm run dev

Update welcome/welcome.tsx (* useState).

// app/welcome/welcome.tsx
import { useState } from "react";
import { MoonIcon, SunIcon } from "lucide-react";

export function Welcome() {
  const [theme, setTheme] = useState("light");
  return (
    <main className="flex flex-col gap-2 items-center justify-center pt-16 pb-4">
      <h1>Welcome!</h1>
      <div className="fixed top-[calc(50%_-_12px)]"></div>

      <a
        onClick={() => {
          const newTheme = theme === 'dark' ? 'light' : 'dark';
          setTheme(newTheme);
          document.documentElement.classList.add(newTheme);
          document.documentElement.classList.remove(theme);
        }}
      >
        {theme === "dark" ? <SunIcon /> : <MoonIcon />}
      </a>
    </main>
  );
}

Let's improve the looks of this (slightly). Add the following to app.css, in the @layer base section (* the @layer directive ).

// app/app.css
  h1 {
    @apply font-bold;
    font-size: 3rem;
  }

Add a className attribute to the <body> tag of the Layout() function in root.tsx.

// app/root.tsx
...
      <body className="h-screen">
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
...

So with that, we have our landing page toggling between light and dark modes.

What if we add another page?

In anticipation of creating more pages, we'll create a reusable theme switcher component. Create a directory in app/ called components/. Add a file theme-switcher.tsx.

// app/components/theme-switcher.tsx
import { useState } from 'react';
import { MoonIcon, SunIcon } from 'lucide-react';

export function ThemeSwitcher() {
  const [theme, setTheme] = useState('light');
  return (
    <a
      onClick={() => {
        const newTheme = theme === 'dark' ? 'light' : 'dark';
        setTheme(newTheme);
        document.documentElement.classList.remove(theme);
        document.documentElement.classList.add(newTheme);
      }}
    >
      {theme === 'dark' ? <SunIcon /> : <MoonIcon />}
    </a>
  );
}

Update welcome.tsx to use our new component.

// app/welcome/welcome.tsx
import { ThemeSwitcher } from '~/components/theme-switcher';

export function Welcome() {
  return (
    <main className="flex flex-col gap-2 items-center justify-center pt-16 pb-4">
      <h1>Welcome!</h1>
      <div className="fixed top-[calc(50%_-_12px)]"></div>
      <ThemeSwitcher />
    </main>
  );
}

In app/ create a directory pages/. Then in pages/, create two files: home.page.tsx and about.page.tsx (* NavLink).

// app/pages/home.page.tsx
import { NavLink } from 'react-router';
import { ThemeSwitcher } from '~/components/theme-switcher';

export function HomePage() {
  return (
    <main className="flex flex-col gap-2 items-center justify-center pt-16 pb-4">
      <h1>Welcome!</h1>
      <div className="fixed top-[calc(50%_-_12px)]"></div>
      <ThemeSwitcher />
      <NavLink to="/about">about</NavLink>
    </main>
  );
}
// app/pages/about.page.tsx
import { NavLink } from 'react-router';
import { ThemeSwitcher } from '~/components/theme-switcher';

export function AboutPage() {
  return (
    <main className="flex flex-col gap-2 items-center justify-center pt-16 pb-4">
      <h1>About.</h1>
      <div className="fixed top-[calc(50%_-_12px)]"></div>
      <ThemeSwitcher />
      <NavLink to="/">home</NavLink>
    </main>
  );
}

Update home.tsx route to refer to our new page (* Routes).

// app/routes/home.tsx
import type { Route } from './+types/home';
import { HomePage } from '~/pages/home.page';

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'The Tao of Light/Dark Mode' },
    {
      name: 'description',
      content:
        'The landing page for `Implementing light and dark mode with React Router v7`',
    },
  ];
}

export default function Home() {
  return <HomePage />;
}

Create a route for the About page.

import type { Route } from './+types/about';
import { AboutPage } from '~/pages/about.page';

export function meta({}: Route.MetaArgs) {
  return [
    { title: 'The Tao of Light/Dark Mode: About' },
    {
      name: 'description',
      content:
        'The "About" page for "Implementing light and dark mode with React Router v7"',
    },
  ];
}

export default function About() {
  return <AboutPage />;
}

Add the About page route to the routes.ts file.

// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
  index('routes/home.tsx'),
  route('about', 'routes/about.tsx'),
] satisfies RouteConfig;

Add the following in the main CSS file, once again in @layer base (* the @apply directive).

// app/app.css
  a {
    @apply underline;
  }

Does it still work? On the Welcome page, click the moon icon. Good—it changes to dark mode. Now click on the "about" link. Nope. The About page isn't properly reflecting the dark mode state. Yes, the background is black but the icon is wrong. Click the moon icon. Refresh the page. Erm. The site isn't retaining our selected light/dark mode state.

The solution

We need some way to communicate the state between pages. For that we'll make use of a React Context (* React Context). Create a directory app/contexts and add a file called theme.context.tsx.

// app/contexts/theme.context.tsx
import { createContext, useContext, useState } from "react";

export enum Theme {
  DARK = "dark",
  LIGHT = "light",
}
export const themeDefault = Theme.LIGHT;

type ThemeProviderProps = {
  children: React.ReactNode;
  startingTheme?: Theme;
};

const ThemeContext = createContext<{
  theme: Theme;
  applyTheme: (theme: Theme) => void;
  setTheme: (theme: Theme) => void;
}>({
  theme: themeDefault,
  applyTheme: () => {},
  setTheme: () => {},
});

export function ThemeProvider({
  children,
  startingTheme = themeDefault,
}: ThemeProviderProps) {
  const [theme, setTheme] = useState(startingTheme);

  const contextValue = {
    theme,
    applyTheme: async (theme: Theme) => {
      setTheme(theme);
    },
    setTheme,
  };

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

Update the toggle component, making use of our newly created context.

// app/components/theme-switcher.tsx
import { MoonIcon, SunIcon } from "lucide-react";
import { Theme, useTheme } from "~/contexts/theme.context";

function ThemeSwitcher() {
  const { theme, applyTheme } = useTheme();

  return (
    <a
      onClick={() =>
        applyTheme(theme === Theme.DARK ? Theme.LIGHT : Theme.DARK)
      }
    >
      {theme === Theme.DARK ? <SunIcon /> : <MoonIcon />}
    </a>
  );
}

export { ThemeSwitcher };

Modify root.tsx with the following:

// app/root.tsx
...
// Add this import.
import { ThemeProvider, useTheme } from "~/contexts/theme.context";
....
// Add a function.
function Document({ children }: { children: React.ReactNode }) {
  const { theme } = useTheme();
  return (
    <html lang="en" className={theme}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body className="h-screen">
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

// Update the Layout() function.
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <Document>{children}</Document>
    </ThemeProvider>
  );
}
...

Let's give it a test. On the landing page, click the theme toggle. That should work. Click the "about" link. Hey, nice! It has the same state as the landing page. Click the theme toggle. Good—that works too. Now, reload the page. Argh.

We need something to retain the state between page reloads. But what's going to hold that state?

Look to the cookie.

More exactly, we'll look to session cookies to hold the state. In app/, create a directory called .server. In the new directory, add a file sessions.server.ts.

// app/.server/sessions.server.ts
import { createCookieSessionStorage } from "react-router";
import { Theme } from "~/contexts/theme.context";

type SessionData = {
  theme: Theme;
};

type SessionFlashData = {
  error: string;
};

const isProduction = process.env.NODE_ENV === "production";

const sessionName = "__session";
const { getSession, commitSession, destroySession } =
  createCookieSessionStorage<SessionData, SessionFlashData>({
    cookie: {
      name: sessionName,
      httpOnly: true,
      maxAge: 60,
      path: "/",
      sameSite: "lax",
      secrets: ["53cr3t"], // <- Provide a proper secret!
      ...(isProduction ? { domain: "<your-domain>", secure: true } : {}),
    },
  });

export { getSession, commitSession, destroySession };

In app/routes/, create a directory called actions. Add a file set-theme.ts with the following contents:

// app/routes/actions/set-theme.ts
import type { Route } from '../+types/about';
import { getSession, commitSession } from '~/.server/sessions.server';
import { Theme } from '~/contexts/theme.context';

export async function action({ request }: Route.ActionArgs) {
  const session = await getSession(request.headers.get('Cookie'));

  const theme = !session.has('theme') ? Theme.DARK : session.get('theme');
  const newTheme = theme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT;
  session.set('theme', newTheme);

  const cookie = await commitSession(session);
  return new Response(null, {
    headers: {
      'Set-Cookie': cookie,
    },
  });
}

In app/routes.tsx, add a route for the action.

// app/routes.tsx
import { type RouteConfig, index, prefix, route } from "@react-router/dev/routes";

export default [
    index("routes/home.tsx"),
    route("about", "routes/about.tsx"),
    ...prefix("action", [
        route("set-theme", "routes/actions/set-theme.ts"),
    ]),
] satisfies RouteConfig;

Update app/root.tsx with the following:

// app/root.tsx
...
// Update the react-router import.
import {
  data, // <- Add this.
  isRouteErrorResponse,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData, // <- Add this.
} from 'react-router';
// Update the theme.context import.
import {
  themeDefault, // <- Add this.
  ThemeProvider,
  useTheme,
} from '~/contexts/theme.context';
// Add this import.
import { commitSession, getSession } from "~/.server/sessions.server";
...
// Add a loader() function.
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSession(request.headers.get("Cookie"));

  if (!session.has("theme")) {
    session.set("theme", themeDefault);
  }
  return data(
    {
      error: session.get("error"),
      theme: session.get("theme"),
    },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
}
...
// Update the Layout() function.
export function Layout({ children }: { children: React.ReactNode }) {
  const data = useLoaderData<typeof loader>();
  const theme = data.theme;
  return (
    <ThemeProvider startingTheme={theme}>
      <Document>{children}</Document>
    </ThemeProvider>
  );
}

Update the ThemeProvider to make a request to the new theme action.

// app/contexts/theme.context.tsx
...
export function ThemeProvider({
  children,
  startingTheme = themeDefault,
}: ThemeProviderProps) {
  const [theme, setTheme] = useState(startingTheme);

  const contextValue = {
    theme,
    applyTheme: async (theme: Theme) => {
      setTheme(theme);

      const _ = await fetch("/action/set-theme", {
        method: "PATCH",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ theme }),
      });
    },
    setTheme,
  };

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
}
...

Run through our test again.

Success! Congratulations on taking this opportunity for growth, learning, and balance ☯︎!

0
Subscribe to my newsletter

Read articles from Rich Burke directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rich Burke
Rich Burke