Reputation: 125
I've been banging my head against this for a few days now. I read all the similar questions, but they don't quite match what I need.
I find it hard to believe no one else has tried, or documented, this before, and I'd love some thoughts on how to approach this.
Requirement
I am using react bootstrap for my app and I want to give users the choice between a set of bootstrap themes. To keep it simple I currently only have a light and a dark one. These styles ship as scss from https://bootswatch.com/ and so I'd rather not try the usual CSS in JS theme approaches.
What I'm trying to do
Ideally, what I want is the ability to conditionally render/import the css files. I've tried a few things most of them don't work and one works sort of, but is really hacky.
What I'd like is a way to construct a DOM node with a style node from the css file and not render it. Then I can put all these style nodes into the DOM and selectively add/remove them.
Ideally, I don't need to manually alter the DOM at all. I haven't been able to find a way to do it. I'd have hoped conditional rendering of my style component would do the trick, but this doesn't work as the style is never loaded (beyond first time) or unloaded (ever).
What I tried so far
Use lazy loading (works sort of)
I put my css in a lightweight component such as
import React from 'react';
import './_light.scss';
const Theme = React.forwardRef(( props, ref) => (<div ref={ref}></div>));
export default Theme;```
Then I lazy load either or in my App like so
const LightTheme = React.lazy(() => import('../themes/lightTheme'));
const DarkTheme = React.lazy(() => import('../themes/darkTheme'));
....
const element = (chosenTheme === PreferredTheme.LIGHT) ?
<LightTheme /> : <DarkTheme />
...
return (
<ThemeContext.Provider value={initialValue}>
<React.Suspense fallback="{<></>}">
{element}
</React.Suspense>
{children}
</ThemeContext.Provider>
)
The problem is that I give the user the choice to toggle themes and what seems to happen is two problems 1) that once both have loaded (once) they never load again 2) ordering of style nodes matters and so whichever was loaded last will always stay active.
I was able to work around this by doing some hacky <head>
node manipulation in my useEffect to delete (no longer used style)/add (to be applied) style nodes to the DOM, but it's hacky at best.
useRef
to get a reference to each style node, save it to a dict and then delete it. This breaks in an interesting way where the reference works the first time the thing renders but on subsequent renders the reference becomes undefined
Creating a stylesheet on the fly
The solution here looks promising How to load/unload css dynamically. Trouble is I don't know how to get to the inner text
of my stylesheet in this scenario. As I said it's shipped as scss so it gets preprocessed and I don't know how I could even get the text.
Any thought or explanations much appreciated.
Upvotes: 6
Views: 6389
Reputation: 13
Here is my solution (looks like @sev solution)
import { useEffect } from 'react';
export const useDynamicStyleSheet = (styleSheet: string): void => {
useEffect(() => {
const styleElement = document.createElement('style');
styleElement.innerHTML = styleSheet;
document.head.append(styleElement);
return () => styleElement.remove();
}, [styleSheet]);
};
import { FC } from 'react';
import { useDynamicStyleSheet } from 'app/hooks/useDynamicStyleSheet';
// eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved
import lightTheme from '!css-loader!antd/dist/antd.css';
const LightTheme: FC = () => {
useDynamicStyleSheet(lightTheme.toString());
return null;
};
Upvotes: 1
Reputation: 125
Right, so I found a quite neat solution now that at least has straightforward logic. I had to learn a bit more about Webpack and how its loaders work. Specifically how to use CRA and still be able to modify it's behaviour. Long story short is that CRA webpack chains the style-loader at the end of scss files and therefore tries to put the css into the DOM as a node (https://webpack.js.org/loaders/style-loader/)
Since I don't want to do that (yet) I need to disable the style-loader that is taken care of by this abomination of an import statement (https://webpack.js.org/concepts/loaders/#inline)
// @ts-ignore
import { default as DarkThemeC } from '!css-loader!sass-loader!../themes/_dark.scss'; // eslint-disable-line import/no-webpack-loader-syntax
// @ts-ignore
import { default as LightThemeC } from '!css-loader!sass-loader!../themes/_light.scss'; // eslint-disable-line import/no-webpack-loader-syntax
Now I have the css as an array at my disposal without rendering it so I put it into a Map for future use.
const availableThemes = new Map<PreferredTheme, string>();
availableThemes.set(PreferredTheme.LIGHT, LightThemeC.toString());
availableThemes.set(PreferredTheme.DARK, DarkThemeC.toString());
This means I can do this in my useEffect callback
useEffect( () => {
// Add new sheet
const newTheme = availableThemes.get(chosenTheme);
if(newTheme) {
// injectCss borrowed from https://stackoverflow.com/questions/60593614/how-to-load-unload-css-dynamically
injectCss(newTheme, chosenTheme);
}
// Remove all others
availableThemes.forEach( (style, theme) => {
if(theme !== chosenTheme) {
var stylesheet = document.getElementById(`dynamicstylesheet${theme}`);
if (stylesheet) {
head.removeChild(stylesheet);
}
}
});
}, [chosenTheme])
Works like a charm, but the only thing I observed is that it's a bit slow on switch (fraction of a second)
Upvotes: 3
Reputation: 11183
Why not specify the theme as a class to the top-level element of your application, and build your CSS classes under that using CSS variables?
#app.dark {
--app-bg: black;
--app-fg: white;
}
#app.light {
--app-bg: white
--app-fg: black;
}
#app {
background-color: var(--app-bg);
color: var(--app-fg);
}
You could indeed use a higher-order component to wrap your UI, and change that based the selected theme, and use Webpack for the lazy loading, if it is worth it. FWIW, my experience is that dark/light themes are easiest and most clearly handled as above.
Upvotes: -2