Some time ago, I created my portfolio website using React + Next.js
. I also added a dark mode toggle switch. π‘
Recently, I found some free time to look at the functionality again. The switch works well but the initial load suffers from a problem. There is a flash of incorrect theme when the page loads for a very small time. The flash can be more noticeable on different devices and network connections.
Below is a write up of how I fixed it for my particular case.
The article does not go over the basics of how to create a dark mode switch using React
(and/or Next.js
) with localStorage
. There are other brilliant articles for that. This article is just a write up showing how one would build on their existing approach to tackle the flicker problem. My portfolio is built on Next.js
, but I think a similar approach can be used for other server side frameworks like Gatsby
.
This article assumes that the reader has basic knowledge of React Context
and Next.js
. I have tried to link to the docs wherever possible.
Table of Contents
- Theme switcher using local storage and context
- The flicker problem
- Using Lazy State Initialisation
- Using cookies
- Customising the Document file
- Summary
Theme switcher using local storage and context
First things first. Here is a basic outline of the initial approach.
The theme is powered by React Context. The user preference is saved in localStorage
. The changes are made using CSS variables.
Here is what context looks like:
const Context = createContext({
theme: "",
toggleTheme: null
});
An object containing theme
value and a method to modify it. Now any component that consumes this context can read the theme value (and modify it, if need be).
The CSS variables are stored in a constants file.
export const colorPalette = {
dark: {
background: "#222629",
paraText: "#fff",
headerText: "#fff",
base: "#fff",
pressed: "#c5c6c8",
shade: "#2d3235"
},
light: {
background: "#fffff",
paraText: "#15202b",
headerText: "#212121",
base: "#212121",
pressed: "#22303c",
shade: "#f5f5f5"
}
};
export const filter = {
dark: {
socialMediaIcon:
"invert(100) sepia(0) saturate(1) hue-rotate(0deg) brightness(100)"
},
light: {
socialMediaIcon: "invert(0) sepia(0) saturate(0) brightness(0)"
}
};
The colorPalette
is self explanatory. The filter
variable is where filters are stored.
Why filter for images? π
It is very likely, that one would want to show logos/images in a different colour for different themes. A trick to do that is by using CSS filters which can change the logo colorsπ¨. (My website is monotone so it was much easier to convert the icons to black and white). This way the page does not have to make request to a new image. On noticing the above GIF, one can see green logos (their original colour) initially, which turn black and white.
Below is the function that changes the colour palette and the filters based on the input theme:
const changeColorsTo = (theme) => {
const properties = [
"background",
"paraText",
"headerText",
"base",
"pressed",
"shade"
];
if (typeof document !== "undefined") {
properties.forEach((x) => { document.documentElement.style.setProperty(
`--${x}`,
colorPalette[(theme === undefined ? "LIGHT" : theme).toLowerCase()][x]
);
});
document.documentElement.style.setProperty(
`--socialIconsfilter`,
filter[(theme === undefined ? "LIGHT" : theme).toLowerCase()]
.socialMediaIcon
);
}
};
setProperty is used to set the CSS variables.
Below is the ContextProvider, which wraps all elements on the webpage.
const ContextProvider = (props) => {
let [currentTheme, setTheme] = useState("LIGHT");
useEffect(() => {
let storageTheme = localStorage.getItem("themeSwitch");
let currentTheme = storageTheme ? storageTheme : "LIGHT";
setTheme(currentTheme);
changeColorsTo(currentTheme);
}, []);
let themeSwitchHandler = () => {
const newTheme = currentTheme === "DARK" ? "LIGHT" : "DARK";
setTheme(newTheme);
window && localStorage.setItem("themeSwitch", newTheme);
changeColorsTo(newTheme);
};
return (
<Context.Provider
value={{
theme: currentTheme,
toggleTheme: themeSwitchHandler
}}
>
{props.children}
</Context.Provider>
);
};
export { Context, ContextProvider };
The currentTheme
is initialised with LIGHT
. After the first mount, the correct theme value is read from localStorage
and updated accordingly. If localStorage is empty, then LIGHT is used.
The themeSwitchHandler
function is called to change the theme. It performs three actions:
- Updates the
CSS variables
by callingchangeColorsTo
, - updates the
localStorage
value, and - sets the new value for
currentTheme
, so the context value is also updated.
The below is the code for _app.js
. With Next.js, one can use a custom App
component to keep state when navigating pages (among other things).
const MyApp = ({ Component, pageProps }) => {
return (
<>
<Head>
....
<title>Tushar Shahi</title>
</Head>
<ContextProvider>
<Layout>
<Component {...pageProps} />
</Layout>
</ContextProvider>
</>
);
};
The relevant part is how ContextProvider
wraps all the components.
The flicker problem
The above code gives a hint as to why there is a flickering problem. Initially there is no information about the user preference. So LIGHT is used as the default theme, and once localStorage
can be accessed, which is inside the useEffect callback (useEffect
with any empty dependency array works like componentDidMount
), the correct theme is used.
How to initialise the state correctly?
An update to the code could be done by utilising lazy initial state.
const setInitialState = () => {
let currentTheme = "LIGHT";
if (typeof window !== "undefined" && window.localStorage) {
let storageTheme = localStorage.getItem("themeSwitch");
currentTheme = storageTheme ? storageTheme : "LIGHT";
}
changeColorsTo(currentTheme);
return currentTheme;
};
const ContextProvider = (props) => {
let [currentTheme, setTheme] = useState(setInitialState);
.....
setInitialState
reads the theme value, changes the color and returns the theme. Because Next.js renders components on the server side first, localStorage
cannot be accessed directly. The usual way to ensure such code runs only on client side is by checking for this condition:
typeof window !== "undefined"
This does not help though. Again, there is a flicker. On top of that there is a hydration error
.
Warning: Text content did not match. Server: "LIGHT" Client: "DARK"
in ModeToggler
component.
The issue: Server side value of theme
is LIGHT
and client side it is DARK
. Understandable because localStorage
is not available server side. This value is rendered as text
in the ModeToggler
component, hence the mismatch.
Using cookies πͺ
The network tab shows the value of theme in the HTML page being served is incorrect.
To fix this, a data store which is accessible to both client and server needs to be used. cookies
is the way. And with Next.js data fetching methods it becomes easy to access them.
Implementing getServerSideProps
on relevant pages does this:
export const getServerSideProps = async ({ req }) => {
const theme = req.cookies.themeSwitch ?? "LIGHT";
return {
props: {
theme
} // will be passed to the page component as props
};
};
The above code runs on every request.
theme
is made used in the MyApp
component.
const MyApp = ({ Component, pageProps }) => {
return(
....
<ContextProvider theme={pageProps.theme}>
<Layout>
<Component {...pageProps} />
</Layout>
</ContextProvider>
....
Now, the prop theme
is used to initialise the state in the ContextProvider
.
const ContextProvider = ({ theme, children }) => {
let [currentTheme, setTheme] = useState(() => {
changeColorsTo(theme);
return theme;
});
let themeSwitchHandler = () => {
const newTheme = currentTheme === "DARK" ? "LIGHT" : "DARK";
setTheme(newTheme);
changeColorsTo(newTheme);
if (document) document.cookie = `themeSwitch=${newTheme}`;
};
return (
<Context.Provider
value={{
theme: currentTheme,
toggleTheme: themeSwitchHandler
}}
>
{children}
</Context.Provider>
);
};
The code using localStorage
is replaced by the code using cookies
. Now the information about correct theme is present on the server side too. Inspecting the network tab confirms that.
But there is still a flicker. π©
The function changeColorsTo
has a check for the existence of document
so that the code manipulating the colors only runs on client side. The loaded html
file shows that the styles are not loaded from the server side. This indicates that the client side code (not the server side code) updates all the CSS variables, even if the correct value of theme is available on the server side.
How to utilise the cookie info to add the styles on server side?
Customising the Document file
_document.js
is used in Next.js to update the html
and body
tags. The file runs on the server side. It is a good place to load fonts and any scripts (both inline and remote).
Document component can implement a getIntialProps
. This is also a data fetching method. It has access to context
and request
. This is where one can access the themeSwitch
cookie and pass it on as a prop.
MyDocument.getInitialProps = async (ctx) => {
const initialProps = await Document.getInitialProps(ctx);
const theme = ctx.req?.cookies?.themeSwitch ?? "LIGHT";
return { ...initialProps, theme };
};
The Document
component can read the theme and create the styles object. This will be added to the html
tag. Now every time any page is served, the html styles will be filled directly by the server.
Why optional chaining to access cookies?
There is a need for the optional chaining operator because getInitialProps
runs for every page served. And 404
pages do not have data fetching methods like getServerSideProps
or getInitialProps
. req
object does not exist for 404.js
and hence accessing cookies
will throw an error.
const MyDocument = ({ theme }) => {
const styleObject = useMemo(() => {
let correctTheme =
colorPalette[(theme === undefined ? "LIGHT" : theme).toLowerCase()];
let correctFilter =
filter[(theme === undefined ? "LIGHT" : theme).toLowerCase()];
const styles = {};
Object.entries(correctTheme).forEach(([key, value]) => {
styles[`--${key}`] = value;
});
styles[`--socialIconsfilter`] = correctFilter.socialMediaIcon;
return styles;
}, [colorPalette, filter]);
return (
<Html lang="en" style={styleObject}>
<Head>
....
</Head>
<body>
<Main />
<NextScript />
....
</body>
</Html>
);
};
The component body creates a stylesObject
using the correct theme with the colorPalette
and filter
object.
Yes. There is no flicker now. π The website is flicker-less.
The network tab shows that the CSS variables are being pre filled when the page is served.
With this set, the context code can be updated. Now it is not required to change colors on the first render. So there is no need to have a function in useState
.
const ContextProvider = ({ theme, children }) => {
let [currentTheme, setTheme] = useState(theme);
Summary
- There is a need to use
cookies
instead oflocalStorage
because information is needed both on client and server side. - Theme can be read from cookies in data fetching methods and passed as props to all the pages.
- Updating the
CSS variables
usingContext
will still cause a flicker because the server rendered page is served with the wrong colors. - To get the correct value in
CSS variables
Next.js'sDocument
component is customised. It can update thebody
& thehtml
and is run on the server side.
The code is deployed on vercel. One might notice that the 404
page does not get the correct theme, because of the implementation.
Hope this is helpful to people reading this.
Top comments (4)
This is my first blog post. I would love to receive feedback from you all about the post, the code, or even the portfolio website itself. Moreover, I would love to see how you handle the theme toggle switch on your projects.
Hello!
Great article!
But there is one problem with Incremental Static Regeneration.
Haven't found a solution yet. What the hell do you think about this?
Thanks.
True. Since pages (with the right theme) require user information to generate the page, they cannot be statically generated.
One approach I had in mind was to create multiple versions of your pages (dark and light) and return them to the user. They will all be statically generated. I think these pages will benefit from ISR. I haven't tried it though.
Thank you!
I would be grateful if you find and share the solution to this problem :)