Logo, una animación de memoji sonriendo

How I Solved Dark/Light Mode in React.

First edit: 1 May 2024 • Stage: Finished, for the moment • Certainty: Far from glory, the code is functional • Importance: Personal

Disclaimer

If you are an experienced developer, you might find this article basic.

My solution to the dark/light theme with CSS, JS, and React. You can read about my thought process and how I implemented it with code examples. There are better ways to achieve the same result, but this is my approach given my current level of programming knowledge.

Thought Process

Since I've built my blog with React and the Next.js framework, there wasn't a specific reason for me to implement the theme switcher using plain CSS without any JavaScript. I utilise Server-Side Rendering and Server-Side Components, where the HTML & CSS are rendered on the server and sent to the client. Certain parts of the navigation and a few other elements rely on React hydration, but users can view the full UI with HTML and CSS, including the theme, even with JavaScript disabled.

In a progressive enhancement sense, this is the flow:

Code

Let's see all in more detail... and with code examples.

CSS

If a user has JS disabled, and is using an old browser it will read only :root. If the browser is modern, using @media (prefers-color-scheme: dark) we apply correct theme based on his OS preferences:

1:root {
2 --clr-primario: 175 42% 90%;
3 --clr-secundario: 161 40% 90%;
4 ...;
5}
6
7@media (prefers-color-scheme: dark) {
8 :root {
9 --clr-primario: 198 37% 15%;
10 --clr-secundario: 198 31% 17%;
11 ...;
12 }
13}

I use custom properties for colours, and since the light theme is chosen by the majority of users as default, that’s what’s in the :root. Older browsers that do not understand @media (prefers-color-scheme: dark) can load variables fine, and the default light theme will be applied.

Inside @media (prefers-color-scheme: dark), we have the same custom properties with different values for the dark theme, and any user with modern browsers can have the dark theme applied.

With this code, we have respected user preferences in their operating system settings. No JS is necessary. That’s fine. Old browsers get the light theme, modern ones as per OS defaults chosen by the user.

React and JS

If the user has JS enabled, the header component will hydrate, and we can use the useState() and useEffect() hooks to implement the theme toggle.

Using the React useEffect() hook, we check via JS the default user OS theme. We do that to synchronise our app state with the user's OS preference and with what is applied on the page by CSS.

1const [isMenuOpen, toggleMenuOpen] = useToggle(false);
2const [darkMode, setDarkMode] = useState(false);
3
4useEffect(() => {
5 if (
6 window.matchMedia &&
7 window.matchMedia("(prefers-color-scheme: dark)").matches
8 ) {
9 setDarkMode(true);
10 } else if (
11 window.matchMedia &&
12 window.matchMedia("(prefers-color-scheme: light)").matches
13 ) {
14 setDarkMode(false);
15 }
16}, []);

Once we are all good and synchronised, and if the user wants to endlessly click the button that switches the theme, let’s give him that pleasure.

Inside the second useEffect() hook, and using the JS .setProperty() method, we flip the theme."

1 useEffect(() => {
2 const root = document.querySelector(":root");
3 if (darkMode) {
4 root.style.setProperty("--clr-primario", "198 37% 15%");
5 root.style.setProperty("--clr-secundario", "198 31% 17%");
6 ...;
7 } else {
8 root.style.setProperty("--clr-primario", "175 42% 90%");
9 root.style.setProperty("--clr-secundario", "161 40% 90%");
10 ...;
11 }
12 }, [darkMode]);

That’s about it...

Final words

I know that this can be done much better, but let’s reserve that for future adventures once I progress more with my knowledge.

Doubts

First, I use two useEffect() hooks. (Edit: Changed to one...) That could be squeezed into one with an if statement inside and another useState() named isFirstRender(true). In that case, the code from the first hook where we sync the state will be placed inside the second hook. I’ll try that soon to see what happens afterwards.

A week later...

I've done it; placed all in one useEffect() and it looks to me that it works better, here is the final code:

1const [isFirstRender, setIsFirstRender] = useState(true);
2
3useEffect(() => {
4 if (isFirstRender) {
5 if (
6 window.matchMedia &&
7 window.matchMedia("(prefers-color-scheme: dark)").matches
8 ) {
9 setDarkMode(true);
10 } else if (
11 window.matchMedia &&
12 window.matchMedia("(prefers-color-scheme: light)").matches
13 ) {
14 setDarkMode(false);
15 }
16 setIsFirstRender(false);
17 return;
18 }
19
20 const root = document.querySelector(":root");
21 if (darkMode) {
22 root.style.setProperty("--clr-primario", "198 37% 15%");
23 root.style.setProperty("--clr-secundario", "198 31% 17%");
24 ...
25 } else {
26 root.style.setProperty("--clr-primario", "175 42% 90%");
27 root.style.setProperty("--clr-secundario", "161 40% 90%");
28 ...
29 }
30 }, [darkMode, isFirstRender]);

The second doubt is about setting a cookie in the browser with user theme preferences and loading that cookie on subsequent visits. However, this complicates things.

I wonder how many users prefer a different theme to their OS default. In my opinion, not many. If this blog were a large app used daily, then setting a cookie would be fine. But for my needs, it is overkill.

Additionally, I do not want to set any cookies because I’m located in the EU, and I do not want that cookie banner thing to pop up on my personal site. No tracking, no cookies, no legal need for a cookie banner.

Lastly, I have not noticed any flash of unstyled HTML or the wrong theme. That doesn’t mean I’m 100% sure there is no flash at all.

Thanks.