Introduction
In the previous posts, we saw how to:
- use CSS to handle different themes,
- handle system themes and also user-picked themes,
- store the previously picked theme for next visits,
- how to avoid theme blink on page reload.
In this post, we'll see how we can use everything together, and add React and a remote database (for fun) in this mix.
The goal is to show the backbone of what could be the actual code you'd use to handle themes in your app.
Table of contents
Flow of the logic we'll implement
The following flow is related to a frontend app, not a server-side rendered website (like what you would have in PHP):
- Users are loading your website
- We are applying (in a blocking way) the previously picked theme (it can be a wrong one)
- A fetch is performed on your database to retrieve their favorite mode (light/dark/system)
- The favorite mode is saved in their browser for future visits
- The mode is saved in a react context (for reactive updates if needed)
- When the mode changes, it is saved locally (for future uses), a request is performed against your database, and the react context is updated.
First visit ever
Your users won't have any entry in your database and they won't have any local data saved either. So we'll use the system mode as a fallback.
First visit on a new browser
Your users won't have any local data, so while the request is being done against your database to retrieve their preferred mode, we'll use the system one to avoid unwanted flashes.
Re-visit
The mode they previously picked on this browser will be initially picked. And then 2 possibilities:
- they haven't changed their preferred mode on another device, so the local one matches the remote one => no differences and no flashes (this is the flow during a page refresh),
- they have changed it, and here we'll have a small flash at the initial re-visit (but we cannot prevent that)
Results
Explanations
HTML
CSS
I went with something simple for the CSS: a data-attribute data-theme
with 2 values light
and dark
, and I'm updating 2 css variables, than in the end control the look of the main body.
And as in all other posts of this series, we need to set the color-scheme
, ensuring that native elements will respond to the correct theme:
:root[data-theme="light"] {
color-scheme: light;
--color: #111;
--background: #fff;
}
:root[data-theme="dark"] {
color-scheme: dark;
--color: #cecece;
--background: #333;
}
body {
color: var(--color);
background: var(--background);
}
Blocking script
As we want to avoid flicker during page loads, I added a small blocking script tag, performing only synchronous actions, that only checks for the most basic requirements to determine to best theme to display:
<script>
const mode = localStorage.getItem("mode") || "system";
let theme;
if (mode === "system") {
const isSystemInDarkMode = matchMedia("(prefers-color-scheme: dark)")
.matches;
theme = isSystemInDarkMode ? "dark" : "light";
} else {
// for light and dark, the theme is the mode
theme = mode;
}
document.documentElement.dataset.theme = theme;
</script>
JavaScript
Base variables
First, we need to determine our variables: I'm gonna use mode
for the saved modes (light / dark / system), and theme
for the visual themes (light / dark):
// Saved mode
type Mode = "light" | "dark" | "system";
// Visual themes
type Theme = "light" | "dark";
React context
As we want to be able to provide some informations about the current mode/theme and also a way for users to change the mode, we'll create a React context containing everything:
const ThemeContext = React.createContext<{
mode: Mode;
theme: Theme;
setMode: (mode: Mode) => void;
}>({
mode: "system",
theme: "light",
setMode: () => {}
});
Initialization of the mode
We'll use a state (as its value can change and it should trigger updates) to store the mode.
With React.useState
, you can provide a function, called a lazy initial state, that will only get called during the 1st render:
const [mode, setMode] = React.useState<Mode>(() => {
const initialMode =
(localStorage.getItem(localStorageKey) as Mode | undefined) || "system";
return initialMode;
});
Database sync
Now that we have a mode
state, we need to update it with the remote database. To do so, we could use an effect, but I decided to use another useState
, which seems weird as I'm not using the returned state, but as mentioned above, lazy initial states are only called during the 1st render.
This allows us to start the backend call during the render, and not after in an effect. And as we're starting the API call earlier, we'll also receive the response faster:
// This will only get called during the 1st render
React.useState(() => {
getMode().then(setMode);
});
Save back the mode
When the mode changes, we want to:
- save it in the local storage (to avoid flashes on reload)
- in the database (for cross-device support)
An effect is the perfect use-case for that: we pass the mode
in the dependencies array, so that the effect will be called every time the mode changes:
React.useEffect(() => {
localStorage.setItem(localStorageKey, mode);
saveMode(mode); // database
}, [mode]);
Initialization of the mode
Now that we have a way to get, save, and update the mode, we need a way to translate it to a visual theme.
For this we will use another state (because theme change should trigger an update).
We'll use another lazy initial state to synchronize the system
mode with the theme users picked for their devices:
const [theme, setTheme] = React.useState<Theme>(() => {
if (mode !== "system") {
return mode;
}
const isSystemInDarkMode = matchMedia("(prefers-color-scheme: dark)")
.matches;
return isSystemInDarkMode ? "dark" : "light";
});
System theme update
If users picked the system
mode, we need to track down if they decide to change it from light to dark while still being in our system mode (which is why we are also using a state for the theme
).
To do so, we'll also use an effect that will detect any changes in the mode. In addition to that, when users are in the system
mode, we'll get their current system theme and start an event listener to detect any changes in their theme:
React.useEffect(() => {
if (mode !== "system") {
setTheme(mode);
return;
}
const isSystemInDarkMode = matchMedia("(prefers-color-scheme: dark)");
// If system mode, immediately change theme according to the current system value
setTheme(isSystemInDarkMode.matches ? "dark" : "light");
// As the system value can change, we define an event listener when in system mode
// to track down its changes
const listener = (event: MediaQueryListEvent) => {
setTheme(event.matches ? "dark" : "light");
};
isSystemInDarkMode.addListener(listener);
return () => {
isSystemInDarkMode.removeListener(listener);
};
}, [mode]);
Apply the theme back to the HTML
Now that we have a reliable theme
state, we can make so that the CSS and the HTML follows this state:
React.useEffect(() => {
// Clear previous theme on the html and set the new one
document.documentElement.dataset.theme = theme;
}, [theme]);
Defining the context
Now that we have all the variables we need, the last thing to do is to wrap the whole app in a context provider:
<ThemeContext.Provider value={{ theme, mode, setMode }}>
{children}
</ThemeContext.Provider>
And when we need to refer to it, we can do:
const { theme, mode, setMode } = React.useContext(ThemeContext);
Conclusion
Handling multiple themes isn't trivial, especially if you want to provide the best experience possible for users while having handy tools for your fellow developers.
Here I only presented one possible way of handling this, and it can be refined, improved, and expanded for other use-cases.
But even if your logic/requirements are different, the flow presented at the beginning shouldn't be that different from the one you should adopt.
And if you want to have a look at the full code I wrote in the example, you can find it here: https://codesandbox.io/s/themes-tbclf.
Top comments (2)
nice tut buddy :) keep it up
Thanks!