Welcome to part 5 of the Remix Todo App Series! This series aims to teach you everything you'll use daily in Remix. In part 4, we improved the todo app by adding a pending UI, making it network-aware and enhancing the user experience.
In this part, we'll explore different ways to implement a theme switcher in Remix, examining the pros and cons of each approach. Finally, we'll implement the theme switcher in our todo app using the method that supports progressive enhancement.
A theme switcher lets users select a visual theme for an app, typically between light and dark modes. Some implementations may also let users match their system's preference.
Depending on your app's needs and your comfort with complexity, there are four main ways to implement a theme switcher:
The simplest way to manage state in a React app is with useState or the Context API for global state. This allows you to store the user's theme, style the app accordingly, and update the state on theme changes. It's simple, easy to implement, and component-scoped.
Here's a theme switcher implementation in a Remix app using useState and the Context API:
But, the drawbacks of this approach are that it requires JavaScript to function, the state isn't available for server-side rendering, and the state isn't persistent across component remounting, page refreshes, or browsing sessions. It fails the test for a good user experience and progressive enhancement.
Another way to manage global state, like a theme, is by using the URL. The URL can act as the single source of truth, allowing us to read and set the theme state from it. This approach makes the UI shareable and ensures the state persists across component remounts, page refreshes, or browsing sessions.
Here's a theme switcher implementation in a Remix app using the URL and the Context API:
But, this method has some drawbacks. It relies on JavaScript to work, doesn't support server-side rendering, and requires the theme state to be appended as a search param on every page. If a user manually enters a URL without the search param, the theme state is lost. While it improves the user experience slightly, it fails for progressive enhancement.
The easiest way to persist a global state, like a theme, beyond the component lifecycle or URL is with local storage. Local storage keeps data across browser sessions without expiration. This makes it ideal for preserving the theme state through component remounting, page refreshes, and browsing sessions.
Here's a theme switcher implementation in a Remix app using local storage and the Context API:
But, like React state or URL search params, this approach requires JavaScript and isn't available during server-side rendering, which causes the UI to flicker. While it improves the user experience compared to the previous approaches, it doesn't support progressive enhancement, as JavaScript is necessary for the theme switcher to work.
A cookie is a small piece of data sent from a server to a user's browser via an HTTP response. The browser can store, create, or modify cookies and send them back to the server on future requests. This allows seamless data sharing between server and browser, making cookies ideal for managing user preferences.
Although cookies require more code and result in a global state that can't be isolated to a single component, they offer significant benefits. Cookies make the state available server-side for rendering, persist across component remounts, page refreshes, browsing sessions, and serve as a single source of truth to eliminate state synchronization issues. Most importantly, they work without JavaScript, making them ideal for progressive enhancement.
To manage theme state with cookies, though it requires more code, the steps are simple. First, create a cookie object, then use it in your server loader and action. In the loader, read the theme from the cookie to style the UI. When the user changes their theme, update the cookie in the action, and Remix reloads the page with the new preference.
Next, update app/types.ts to include the Theme type:
Finally, copy the following code into app/lib/theme-cookie.server.tsx:
This code may seem extensive, so let's break it down. First, we create a cookie object using the createCookie utility from Remix, which creates a logical container for managing a browser cookie on the server. We name the cookie theme and set its maxAge attribute to one year, while defaults are used for other cookie attributes that aren't specified. Next, we define three helper functions: validateTheme, parseTheme, and serializeTheme. validateTheme is a type guard that checks if a value is a valid theme, returning true if the value is valid or false otherwise. parseTheme reads the theme from the request Cookie header, returning the theme or defaulting to "system". Finally, serializeTheme clears the cookie if the theme is "system" or updates it with the new value otherwise.
With the cookie object created, the next step is to read the theme from the cookie and apply it to the UI. Since we're using Tailwind CSS and want users to manually select a theme instead of relying on the operating system's preference, we need to add the selector strategy to tailwind.config.ts. Then, based on the cookie's theme value, we'll add the dark class to the <html> element.
First, update tailwind.config.ts to use the selector strategy:
Next, add a loader function for the root route to read the theme from the cookie:
The Vary: Cookie header informs caching mechanisms that the response may vary based on the Cookie header in the request.
Next, we'll style the UI using the theme value returned from the root route loader. But before that, let’s create a ThemeScript.tsx file in app/components/ that contains a utility function to read the theme value. Run the following command:
Copy the following code into app/components/ThemeScript.tsx:
The useRouteLoaderData hook accesses data returned from a route's loader outside the route's default component. The value passed to useRouteLoaderData must be the path of the route file relative to the app folder, excluding the extension. In this case, useTheme retrieves the root loader data and returns the theme.
Finally, update app/root.tsx to use the theme value read from the cookie to style the UI:
The final step is updating the cookie when the user selects a theme. We'll need a ThemeSwitcher component that enables users to select a theme. Upon selecting a theme, an action will be called to update the cookie with the new value.
First, we need icons for the ThemeSwitcher component. Run this command to create files for each icon:
Next, add the respective code for each icon:
Now, create the file for the ThemeSwitcher component:
Copy the following code into app/components/ThemeSwitcher.tsx:
The ThemeSwitcher displays the current theme and opens a menu with theme options. Selecting an option sends a POST request to _actions/theme with the selected theme and a redirect URL (which is the current page) as the request data.
Next, create the _actions/theme route to handle the POST request. We're not doing this in the index route to keep the code and logic clean. Run the following command to create the _actions/theme route:
Copy the following code into app/routes/[_]actions/theme.tsx:
The action throws a 400 response if no theme or an invalid one is provided. It validates the redirect URL, defaulting to the index route if invalid. Finally, it redirects with the theme serialized via serializeTheme from theme-cookie.server.tsx.
With all that done, we can now test our implementation. Import the ThemeSwitcher component into app/routes/_index.tsx:
Once done, you should be able to select a theme and see the app's color scheme change.
This implementation works with or without JavaScript, meaning the theme switcher is available to all users. How sweet 😊. Now, let's enhance it for those with JavaScript enabled.
Did you notice when selecting a theme, the server action completes and Remix revalidates before the theme updates? Also, selecting "system" defaults to light, even if your system prefers dark mode. Let's fix that.
First, we'll optimistically update the theme immediately after selection, then revert to the cookie's state once Remix revalidates. Update app/components/ThemeScript.tsx with the following changes:
Now, the app's theme will instantly change when a theme is selected, with no delay.
To resolve the second issue, we'll need a script and useEffect. On page load, the script will automatically apply the dark class to the <html> element if the theme is "system" and the system prefers dark mode. The useEffect will run when theme changes, ensuring the dark class is applied based on the system's preference.
Update app/components/ThemeScript.tsx with the following changes to create a ThemeScript component:
Then, include the ThemeScript component in the <head> of app/root.tsx:
Now, selecting "system" correctly reflects your system's preference, and the script will run on page load or refresh to set the theme accordingly. Huzzah!
In this part of the series, you learned how to implement a theme switcher in Remix. You set up the switcher to support light and dark modes and sync with the system's preferences.
In the next part, you'll learn how to deploy your Remix app to production. Stay tuned!