I am building a React component library whose source code takes this general structure:
- src
- common.scss (contains things like re-usable css variables)
- components
- button
- index.js
- button.scss
- dialog
- index.js
- dialog.scss
My components are responsible for importing their own per-component styles (using scss), so for example, button/index.js
has this line:
import "./button.scss";
So far, in my application I have been consuming my library directly from source like this:
// app.js
import "mylib/src/common.scss" // load global styles
import Button from 'mylib/src/components/button/index.js'
import Dialog from 'mylib/src/components/dialog/index.js'
// ...application code...
When my application uses webpack, along with style-loader
, the per-component css is appended as style
tags in head
dynamically when the component is first used. This is a nice performance win since the per-component styling doesn't need to be parsed by the browser until it's actually needed.
Now though, I want to distribute my library using Rollup, so application consumers would do something like this:
import { Button, Dialog } from 'mylib'
import "mylib/common.css" // load global styles
// ...application code...
When I use rollup-plugin-scss
it just bundles the per-component styles all together, not dynamically adding them as before.
Is there a technique I can incorporate into my Rollup build so that my per-component styles are dynamically added as style
tags in the head
tag as they are used?
One approach would be to load your SCSS as a CSS stylesheet string the output:false
option in the plugin (see the Options section of the docs), then in your component use react-helmet
to inject the stylesheet at runtime:
import componentCss from './myComponent.scss'; // plain CSS from rollup plugin
import Helmet from 'react-helmet';
function MyComponent(props) {
return (
<>
<ActualComponentStuff {...props} />
<Helmet>
<style>{ componentCss }</style>
</Helmet>
</>
);
}
This basic idea should work, but I wouldn't use this implementation for 2 reasons:
MyComponent
will cause the stylesheet to be injected twice, causing lots of unnecessary DOM injectionHelmet
instance into a nice wrapper)Therefore you're better off using a custom hook, and passing in a uniqueId
that allows your hook to de-duplicate stylesheets. Something like this:
// -------------- myComponent.js -------------------
import componentCss from "./myComponent.scss"; // plain CSS from rollup plugin
import useCss from "./useCss";
function MyComponent(props) {
useCss(componentCss, "my-component");
return (
<ActualComponentStuff {...props} />
);
}
// ------------------ useCss.js ------------------
import { useEffect } from "react";
const cssInstances = {};
function addCssToDocument(css) {
const cssElement = document.createElement("style");
cssElement.setAttribute("type", "text/css");
//normally this would be dangerous, but it's OK for
// a style element because there's no execution!
cssElement.innerHTML = css;
document.head.appendChild(cssElement);
return cssElement;
}
function registerInstance(uniqueId, instanceSymbol, css) {
if (cssInstances[uniqueId]) {
cssInstances[uniqueId].symbols.push(instanceSymbol);
} else {
const cssElement = addCssToDocument(css);
cssInstances[uniqueId] = {
symbols: [instanceSymbol],
cssElement
};
}
}
function deregisterInstance(uniqueId, instanceSymbol) {
const instances = cssInstances[uniqueId];
if (instances) {
//removes this instance by symbol
instances.symbols = instances.symbols.filter(symbol => symbol !== instanceSymbol);
if (instances.symbols.length === 0) {
document.head.removeChild(instances.cssElement);
instances.cssElement = undefined;
}
} else {
console.error(`useCss() failure - tried to deregister and instance of ${uniqueId} but none existed!`);
}
}
export default function useCss(css, uniqueId) {
return useEffect(() => {
// each instance of our component gets a unique symbol
// to track its creation and removal
const instanceSymbol = Symbol();
registerInstance(uniqueId, instanceSymbol, css);
return () => deregisterInstance(uniqueId, instanceSymbol);
}, [css, uniqueId]);
}
This should work much better - the hook will use effectively a app-wide global to track instances of your component, add the CSS dynamically when it gets first rendered, and remove it when the last component dies. All you need to do is add that single hook as an extra line in each of your components (assuming you're using only function React components - if you're using classes you'll need to wrap them, maybe using a HOC or similar).
It should work fine, but it also has some drawbacks:
We're effectively using global state (cssInstances
, which is kind of unavoidable if we're trying to prevent clashes from different parts of the React tree. I was hoping there would be a way to do this by storing state in the DOM itself (this makes sense given that our de-duplication stage is the DOM), but I couldn't find one. Another way would be to use the React Context API instead of a module-level global. This would work fine too and be easier to test; shouldn't be hard to rewrite the hook with useContext()
if that's what you want, but then the integrating app would need to set up a Context provider at the root level and that creates more work for integrators, more documentation, etc.
The dominant approach at the moment seems to be JSS - using something like nano-renderer to turn JS objects into CSS and then injecting them. There doesn't seem to be anything I can find that does this for textual CSS.
Hope this is a useful answer. I've tested the hook itself and it works fine, but I'm not totally confident with Rollup so I'm relying on the plugin documentation here. Either way, good luck with the project!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With