24 January, 2022 | 20 min read

The Complete Guide to Dark Mode with Remix

Remix is an exciting new React framework that promises to help us “build better websites.” Despite only being open-sourced on 22 November 2021, it finished the year as #4 in the JavaScript Rising Stars React category.

Screenshot of JavaScript Rising Stars React category

I wanted to see how dark mode would be implemented with Remix. So I turned to the largest open-source app built using Remix - Kent C. Dodds’ blog.

In this post, we’ll look at how Kent implemented dark mode. We’ll cover:

  • How Remix helps us easily manage a cookie session.
  • How to call backend actions that aren’t associated with a specific route.
  • How to make sure the pre-hydration state aligns with the post-hydration state.

Let’s take a look at how we’re going to eat this elephant:

  1. Adding dark mode without a toggle. Remix makes it unbelievably easy to add a theme based on the user’s system preferences.
  2. Creating a naive switch. We’ll use React context to store our theme value and toggle it when a button is clicked.
  3. Choosing the initial state from user preference. We’ll choose our initial state dynamically based on the user’s system preference. We’ll also look at how to get the correct initial theme before the page has hydrated.
  4. Remembering the user’s choice. We’ll store the user’s preference as a cookie so we remember it for future sessions. This is where the power of Remix really shines.
  5. Finishing touches

This is a long post so if you feel like you already know how to do certain steps, feel free to jump ahead to the later steps.

0. Adding dark mode without toggle

Let’s look at two ways of adding dark mode: using CSS variables and using Tailwind.

CSS Variables

To add a dark mode using CSS variables, create a variable for each colour that will be affected by the change in themes. Then create a second CSS file that defines the “dark” variant of those variables.

styles.css
html {
  --text-colour: black;
  --background-colour: white;

  color: var(--text-colour);
  background-color: var(--background-colour);
}
dark.css
html {
  --text-colour: white;
  --background-colour: black;
}

Both stylesheets can be added in the usual Remix way. We can use the link media query prefers-color-scheme: dark for the dark styles. This will give the <link> tag a media attribute so that the stylesheet is only applied if the user’s system settings prefer dark mode.

import type { LinksFunction } from '@remix-run/node';
import mainStyles from './styles.css';
import darkStyles from './dark.css';

export const links: LinksFunction = () => {
  return [
    {
      rel: 'stylesheet',
      href: mainStyles,
    },
    {
      rel: 'stylesheet',
      href: darkStyles,
      media: '(prefers-color-scheme: dark)',
    },
  ];
};

Tailwind

According to the Remix docs, “the most popular way to style a Remix application in the community is to use tailwind”. Adding dark mode to an application using Tailwind is as simple as updating tailwind.config.js to use a media dark mode strategy:

tailwind.config.js
module.exports = {
  darkMode: 'media',
  // ...
};

Then we add our dark mode styling by using dark:{class} and tailwind will handle adding the media query for us.

<div className="bg-white dark:bg-gray-900">
  <h1 className="text-gray-900 dark:text-white">Hello world</h1>
</div>

1. Naive Dark Mode Switch

Rather than just choose our theme based on the user’s system preferences, let’s allow them manually choose a theme. Here’s our initial approach:

The initial dark mode approach
  • We’ll wrap our app in a Provider which will store our current theme value.
  • The <html> tag will be given a class based on what value the Provider has.
  • The CSS will update based on which class the <html> tag has.
  • Our theme toggle will toggle the value stored by the Provider.

Create the Theme Provider

Let’s start with our Provider. We’ll hold the current theme value in state (with light mode as the default). Then we’ll create a hook, useTheme, that can be used to get the current theme value and a setTheme function.

utils/theme-provider.tsx
import { createContext, useContext, useState } from 'react';
import type { Dispatch, ReactNode, SetStateAction } from 'react';

enum Theme {
  DARK = 'dark',
  LIGHT = 'light',
}

type ThemeContextType = [Theme | null, Dispatch<SetStateAction<Theme | null>>];

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme | null>(Theme.LIGHT);

  return <ThemeContext.Provider value={[theme, setTheme]}>{children}</ThemeContext.Provider>;
}

function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

export { Theme, ThemeProvider, useTheme };

Set the HTML ClassName

Now that we have our theme value available from context, we can set the className on our html tag.

Remix makes this easy for us. Rather than just exposing a section of the body, Remix lets us “[render] everything about our app. From the <html> to the </html>.”

We can wrap our root component in our ThemeProvider. This will let us use useTheme in the App component to get the current theme and set the html element’s class.

root.tsx
import clsx from 'clsx';
import { ThemeProvider, useTheme } from '~/utils/theme-provider';

function App() {
  const [theme] = useTheme();

  return (
    <html lang="en" className={clsx(theme)}>
      {/* ... */}
    </html>
  );
}

export default function AppWithProviders() {
  return (
    <ThemeProvider>
      <App />
    </ThemeProvider>
  );
}

Update the Styling

We’ll use the same general approach for styling as we did for step 0, with a few tweaks.

CSS Variables: Instead of defining the dark CSS variables in a separate CSS file, let’s move them all to the same file but have the dark-mode variables apply when HTML has the class “dark”.

styles.css
html.dark {
  --text-colour: white;
  --background-colour: black;
}

Tailwind: Update the config to use the class strategy.

tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
};

Add a Toggle

Finally, let’s create a route with a button that toggles the theme.

routes/index.tsx
import { Theme, useTheme } from '~/utils/theme-provider';

export default function IndexRoute() {
  const [, setTheme] = useTheme();

  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT));
  };

  return <button onClick={toggleTheme}>Toggle</button>;
}

2. Initial State From User Preferences

Instead of using light as everyone’s default, we’ll check to see if they’ve set a media preference in their system settings. If they’ve said they prefer dark mode, we can use dark as our initial state.

The complication comes because we’re using server-side rendering (SSR). When the initial state is being calculated on the server, it won’t know what the user’s media preferences should be - it’ll have to wait for the client to calculate that.

Fortunately, useState allows us to pass an initialiser function to set the initial state. We can test if the global window variable has been defined. If it is, we know we’re on the client and we can try and get the user’s media preferences. Otherwise, we’ll return null.

Decision tree for choosing initial state

Let’s head over to our ThemeProvider and update the initial state of theme.

utils/theme-provider.tsx
const prefersDarkMQ = '(prefers-color-scheme: dark)';
const getPreferredTheme = () => (window.matchMedia(prefersDarkMQ).matches ? Theme.DARK : Theme.LIGHT);

function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme | null>(() => {
    // there's no way for us to know what the theme should be in this context
    // the client will have to figure it out before hydration.
    if (typeof window !== 'object') {
      return null;
    }

    return getPreferredTheme();
  });

  return <ThemeContext.Provider value={[theme, setTheme]}>{children}</ThemeContext.Provider>;
}

LGTM, ship it! 🚀🚀

But wait… it doesn’t work. When a “prefers dark mode” user opens our app, the app’s initial theme is still light mode. And when the user clicks the “toggle” button, the first click doesn’t do anything 😱

Let’s debug to see what is happening here.

  • Our initial state in the provider is correct, but the class isn’t being added to our html tag.
  • We’re also getting an error in the console complaining that our server and client states are out of sync.

Prop className did not match. Server: "" Client: "dark"

Our initial HTML is being rendered on the server, where our initial theme is null. This is rendered before hydration, meaning before the JavaScript on the page is loaded. So our initial <html> element has a class of "".

Then the JavaScript runs and calculates that the initial theme should be dark, and therefore the initial className should be "dark". The error comes from this mismatch between the server and client.

This is similar to the problem we faced when adding dark mode to an ElderJS app. The solution is also the same - we need to add a script tag to the head of the HTML. The script tag is blocking, so it will run before the rest of the body is rendered. We can use this to ensure our initial, pre-hydration state matches our post-hydration state.

We want the code in the script tag to be run in the script tag, not by React, so we need to define it as a string.

utils/theme-provider.tsx
const clientThemeCode = `
;(() => {
  const theme = window.matchMedia(${JSON.stringify(prefersDarkMQ)}).matches
    ? 'dark'
    : 'light';
  const cl = document.documentElement.classList;
  const themeAlreadyApplied = cl.contains('light') || cl.contains('dark');
  if (themeAlreadyApplied) {
    // this script shouldn't exist if the theme is already applied!
    console.warn(
      "Hi there, could you let Matt know you're seeing this message? Thanks!",
    );
  } else {
    cl.add(theme);
  }
})();
`;

function NonFlashOfWrongThemeEls() {
  // It should be double curly brackets but for some reason
  // my markdown doesn't like it ¯\_(ツ)_/¯
  return <script dangerouslySetInnerHTML={ __html: clientThemeCode } />;
}

// ...

export { NonFlashOfWrongThemeEls, Theme, ThemeProvider, useTheme };
root.tsx
import { NonFlashOfWrongThemeEls, ThemeProvider, useTheme } from '~/utils/theme-provider';

// ...

function App() {
  const [theme] = useTheme();

  return (
    <html lang="en" className={clsx(theme)}>
      <head>
        {/* ... */}
        <NonFlashOfWrongThemeEls />
      </head>
      <body>{/* ... */}</body>
    </html>
  );
}

The code is similar to our post-hydration code.

Because we’re applying the class to the <html> using JavaScript, we make sure there hasn’t already been a theme class applied. In theory, that should never happen (otherwise we wouldn’t need this script!) but it’s better to be safe than sorry.

3. Persisting the User’s Choice

Now that we’re defaulting to the user’s system preference, it would be nice if we remembered if the user chooses a different theme. Then, when they (hopefully) return to our app, we can show them their preferred theme instead of their default theme.

This is where Remix is really going to shine.

The Final Plan

We’re going to use a session cookie to store the user’s theme. Every time the user changes the theme value, we’ll post the new value to a backend action. This will update the theme value in the cookie and set the Set-Cookie header in the response to the new, committed cookie session.

The final plan

Create the Session

Remix gives us an abstraction to help us manage cookie sessions, createCookieSessionStorage which is exactly what we need.

Let’s start by creating a new file, utils/theme.server.ts.

utils/theme.server.ts
import { createCookieSessionStorage } from '@remix-run/node';

import { Theme, isTheme } from './theme-provider';

const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
  throw new Error('SESSION_SECRET must be set');
}

const themeStorage = createCookieSessionStorage({
  cookie: {
    name: 'my_remix_theme',
    secure: true,
    secrets: [sessionSecret],
    sameSite: 'lax',
    path: '/',
    httpOnly: true,
  },
});

async function getThemeSession(request: Request) {
  const session = await themeStorage.getSession(request.headers.get('Cookie'));
  return {
    getTheme: () => {
      const themeValue = session.get('theme');
      return isTheme(themeValue) ? themeValue : null;
    },
    setTheme: (theme: Theme) => session.set('theme', theme),
    commit: () => themeStorage.commitSession(session),
  };
}

export { getThemeSession };

There’s a lot going on here, so let’s step through it slowly.

First, we create our cookie session storage with the aptly named createCookieSessionStorage. This will handle all the session storage for us - we don’t need any backend services or databases to use it.

The options for the cookie come from the attributes in the MDN Set-Cookie docs. We’re going to let the platform do its thing.

The only exception is secrets, which isn’t a Set-Cookie attribute. Secrets are used to sign a cookie’s value. The server then uses the secrets to verify that the value hasn’t been modified or tampered with.

We want the value of secrets to be, well, secret. For this example, we’ll just load the secret from an environment variable. This means we’ll need to set the environment variable in our .env file. Then, during development, we can use dotenv to load the variable when the app starts.

Now that we’ve created the cookie session storage, we’ll create getThemeSession. It will take the cookie value from a request header and use it to get the current cookie session. We’ll then return an object with three helper functions:

  • getTheme - returns the current theme value stored in the cookie session (or null if it isn’t a valid value).
  • setTheme - sets the current theme value in the cookie.
  • commit - stores all the data in the Session and returns the Set-Cookie header. We’ll need this for our HTTP responses later.

You may have also noticed we’re using a new function, isTheme, from utils/theme-provider.tsx. It checks whether an unknown value is a valid theme value and allows TypeScript to narrow the type to Theme.

utils/theme-provider.tsx
// ...

const themes: Array<Theme> = Object.values(Theme);

function isTheme(value: unknown): value is Theme {
  return typeof value === 'string' && themes.includes(value as Theme);
}

export { isTheme, NonFlashOfWrongThemeEls, Theme, ThemeProvider, useTheme };

Create “Set Theme” Action

Now whenever the theme is updated by the user, we want to let the server know so it can update the value of the cookie. Usually, our server actions are associated with a particular route. In this case, though, we want to call our action from our theme provider, which isn’t a route.

We need a route that will define an action but which won’t render anything. We can think of this route as an API endpoint on the server. We’ll be able to call this action by using the Remix hook, useFetcher.

Let’s create our route, routes/action/set-theme.tsx.

routes/action/set-theme.tsx
import { redirect } from '@remix-run/node';
import type { ActionFunction, LoaderFunction } from '@remix-run/node';

export const action: ActionFunction = async ({ request }) => {
  // TODO
};

export const loader: LoaderFunction = () => redirect('/', { status: 404 });

By not returning any default component, Remix will use it as a resource route.

Now let’s finish our action. This is what we’ll be calling when the user changes their theme.

routes/action/set-theme.tsx
import { json, redirect } from '@remix-run/node';
import type { ActionFunction } from '@remix-run/node';

import { getThemeSession } from '~/utils/theme.server';
import { isTheme } from '~/utils/theme-provider';

export const action: ActionFunction = async ({ request }) => {
  const themeSession = await getThemeSession(request);
  const requestText = await request.text();
  const form = new URLSearchParams(requestText);
  const theme = form.get('theme');

  if (!isTheme(theme)) {
    return json({
      success: false,
      message: `theme value of ${theme} is not a valid theme`,
    });
  }

  themeSession.setTheme(theme);
  return json({ success: true }, { headers: { 'Set-Cookie': await themeSession.commit() } });
};

We use the request object to get the current theme session and the new theme value.

The new theme value comes from request.text() which gives us the request body. However, it’s in the form of a query string (e.g. 'theme=light') so we need to extract the theme value. The easiest way to do that is by using URLSearchParams to read our query string and then .get() the value for the key we want (in this case, 'theme').

If the theme value is valid, we can update the theme session and return a success response. This is where we set the Set-Cookie header (using the cookie storage commitSession function).

Now we have our action, we can use useFetcher to call it whenever the theme updates.

theme-provider.tsx
function ThemeProvider({ children }: { children: ReactNode }) {
  // ...

  const persistTheme = useFetcher();

  // TODO: remove this when persistTheme is memoized properly
  const persistThemeRef = useRef(persistTheme);
  useEffect(() => {
    persistThemeRef.current = persistTheme;
  }, [persistTheme]);

  const mountRun = useRef(false);

  useEffect(() => {
    if (!mountRun.current) {
      mountRun.current = true;
      return;
    }
    if (!theme) {
      return;
    }

    persistThemeRef.current.submit({ theme }, { action: 'action/set-theme', method: 'post' });
  }, [theme]);

  // ...
}

We’re using a useEffect to watch for changes to the theme value and send any updates to the backend.

We only want this useEffect to run when the theme changes to a non-falsy value. The useEffect will run when the provider is initially mounted (despite the theme not being changed), so we use a mountRun ref to check if it’s the initial mount and return without updating the cookie if it is.

Finally, we use the value returned from useFetcher to submit the new theme to the action.

  • We pass in the new theme value in the body.
  • In the options, we define which route we would like to call the action of (in this case, routes/action/set-theme.tsx) and the method of submitting our data (in this case, a POST request).

Now we have our theme value persisted in a cookie session and we’re updating our cookie every time the theme changes. Now we can use that value to set the theme for the initial render if the user has previously chosen a theme preference.

Let’s start by adding a loader to our root route, root.tsx, where we’ll return the theme from the theme session. This will be type Theme if there is a saved theme or null if a preference hasn’t been saved.

root.tsx
import type { LoaderFunction } from '@remix-run/node';
import type { Theme } from '~/utils/theme-provider';
import { getThemeSession } from './utils/theme.server';

export type LoaderData = {
  theme: Theme | null;
};

export const loader: LoaderFunction = async ({ request }) => {
  const themeSession = await getThemeSession(request);

  const data: LoaderData = {
    theme: themeSession.getTheme(),
  };

  return data;
};

// ...

Our component, AppWithProviders, can pass this value to our theme provider so that it can calculate the initial state.

root.tsx
import { useLoaderData } from '@remix-run/react';

// ...

export default function AppWithProviders() {
  const data = useLoaderData<LoaderData>();

  return (
    <ThemeProvider specifiedTheme={data.theme}>
      <App />
    </ThemeProvider>
  );
}

In utils/theme-provider.tsx, we’ll update our provider so that it takes the specified theme, checks that it is a valid theme, and if so, sets the initial theme value to that value.

utils/theme-provider.tsx
function ThemeProvider({
  children,
  specifiedTheme,
}: {
  children: ReactNode;
  specifiedTheme: Theme | null;
}) {
  const [theme, setTheme] = useState<Theme | null>(() => {
    if (specifiedTheme) {
      if (themes.includes(specifiedTheme)) {
        return specifiedTheme;
      } else {
        return null;
      }
    }

	// ...
  }

  // ...
};

Finally, if we already know the theme preference for the initial render, we no longer need our script tag to check the user’s preference before hydration. Let’s also pass a flag to NonFlashOfWrongThemeEls to let it know whether the theme is coming from server side rendering.

root.tsx
function App() {
  const data = useLoaderData<LoaderData>();

  // ...

  return (
    <html lang="en" className={clsx(theme)}>
      <head>
        {/* ... */}
        <NonFlashOfWrongThemeEls ssrTheme={Boolean(data.theme)} />
      </head>
      <body>{/* ... */}</body>
    </html>
  );
}
utils/theme-provider.tsx
function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) {
  return <>{ssrTheme ? null : <script dangerouslySetInnerHTML={ __html: clientThemeCode } />}</>;
}

We have a working, persisted dark mode 🎉🎉🎉

And the dev tools show us that our cookie is being successfully created and stored.

The cookie is visible in Chrome dev tools

Finishing Touches

If the user updates their system theme preference while they have our app open, we can assume that they will want our app’s theme to match their latest system preference. We can do this by adding an event listener to the match media query to update the theme if it changes.

utils/theme-provider.tsx
function ThemeProvider({ children, specifiedTheme }: { children: ReactNode; specifiedTheme: Theme | null }) {
  // ...

  useEffect(() => {
    const mediaQuery = window.matchMedia(prefersDarkMQ);
    const handleChange = () => {
      setTheme(mediaQuery.matches ? Theme.DARK : Theme.LIGHT);
    };
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);

  // ...
}

Finally, let’s add a color-scheme meta tag to improve the dark mode default styling.

We first need to update NonFlashOfWrongThemeEls to also add a meta tag based on the theme value.

utils/theme-provider.tsx
function NonFlashOfWrongThemeEls({ ssrTheme }: { ssrTheme: boolean }) {
  const [theme] = useTheme();

  return (
    <>
      <meta name="color-scheme" content={theme === 'light' ? 'light dark' : 'dark light'} />
      {/* ... */}
    </>
  );
}

However, there will be cases where the user hasn’t chosen a preference yet and our initial theme is null (i.e. the SSR can’t know what theme preference the user has). We’ll have to use the same approach we used to define our initial theme value and update clientThemeCode so that it uses the right meta tag before hydration too.

utils/theme-provider.tsx
const clientThemeCode = `
;(() => {
  // ...

  const meta = document.querySelector('meta[name=color-scheme]');
  if (meta) {
    if (theme === 'dark') {
      meta.content = 'dark light';
    } else if (theme === 'light') {
      meta.content = 'light dark';
    }
  } else {
    console.warn(
      "Hey, could you let Matt know you're seeing this message? Thanks!",
    );
  }
})();
`;

The Power Of Remix

Implementing dark mode is a good way to find the limits of what a framework will let you do. With other frameworks, the area of concern is just a portion of the <body> after it has been hydrated. But Remix gives you full control of the HTML from the moment it loads.

You can find the finished code in the Remix examples. And if you have any questions, feel free to message me on Twitter!

Related Posts


Follow me on Twitter or Bluesky to get updates on new posts.