TL;DR: It's like clsx, but for `style` props.
This is the first post in what I hope will become a series called "Am I holding this wrong?" The idea is to do a short post where I share something I've come up with, but where I'm not 100% convinced if I'm actually doing it right, if it makes sense and/or maybe not even worth it at all 😄.
Note: The examples you'll find in this post are for Preact, but they should also work for React with a few minor modifications, at most.
Background
clsx (or classnames)
When you're building reusable components, you may want to offer a way for the consumer of that component to modify or even override styles. For example, you have a generic Button
component, and then you want to do a more specific ShareButton
that uses Button
and all of its default styles, but adds or modifies some of them.
The excellent clsx package offers such functionality for CSS classes, so you can have default/local styles for a component, but also allow the consumer to bring his/her own.
import css from './Component.module.css';
function Component( props ) {
const {
class: className,
...restProps
} = props;
const classes = clsx(
css.Component,
className // <= bring youw own!
);
return (
<div
class={ classes }
{ ...restProps }
/>
);
}
It also makes it very easy to do conditional classes, like this example where we add a visible
class when props.visible
is true
:
const classes = clsx(
css.Component,
className,
{
'visible': visible === true,
}
);
...but for style
So what if I want to do something similar for style
? In (P)react, you can pass an object to the style
prop, so it's fairly easy to do clsx-y things:
function Component( props ) {
const {
style,
visible
...restProps
} = props;
const styles = {
...style,
display: visible ? 'block' : 'none',
};
return (
<div
style={ styles }
{ ...restProps }
/>
);
};
This works fine, but the object you pass to style
will be a new reference each time the component is rendered, and that causes unnecessary updates.
The useStyle hook
useMemo!
What I've come up with is basically wrapping the styles
object in useMemo
.
To solve the issue of not knowing the dependencies upfront, I convert the style object to a flat array of keys and values, and pass that to the dependency array of useMemo
. I also pass the length of the array, so useMemo
will update whenever a value is added or removed:
function useStyle( style ) {
// Convert to flat array of keys and values:
// [ 'width', '100%' ]
const deps = Object.entries( styles ).flat();
return useMemo(
() => styles,
// Pass length and each entry:
// [ 2, 'width', '100%' ]
[ deps.length, ...deps ]
);
}
const styles = useStyle({ width: '100%' });
// styles:
{
width: '100%',
}
I didn't come up with this 'dynamic dependency'-trick myself, btw, but I can't remember where I picked it up...
Flat!
Just as a nice-to-have, I also flatten the styles
object to be able to pass nested objects. With that, we have the option to make the code a bit more organized:
const style = { width: '100%' };
const conditionalStyle = {
'display': visible ? 'block' : 'none',
};
const styles = useStyle({
style,
conditionalStyle
});
// styles:
{
width: '100%';
display: 'block' || 'none',
}
Filter!
Finally, we pipe the flattened entries through a simple Boolean
filter, so we have more flexibility, syntax-wise:
const style = { width: '100%' };
const conditionalStyles {
// Either '1px solid #f00' or false (= removed)
'outline': outline && '1px solid #f00',
// Either 'the value of color' or null (=removed)
'--color': color,
};
const styles = useStyle({
style,
conditionalStyle
});
// styles:
{
width: '100%',
outline: '1px solid #f00' || false,
'--color': '#f00' || null,
}
Code and example
So all of the above combined looks a bit like this:
/**
*
*/
export function useStyle( styles ) {
// Build a flattened styles object
const flattened = _flat( styles, Infinity );
// Build a flat array of keys and values
const deps = Object.entries( flattened ).flat();
return useMemo(
// Boolean-filter the flattened array
() => _filter( flattened, Boolean ),
// We're sure the deps are correct, disable eslint warning.
// eslint-disable-next-line react-hooks/exhaustive-deps
[ deps.length, ...deps ]
);
}
/**
* Equivalent of Array.flat(), but for objects.
* @private
*/
function _flat( obj, depth=1, _depth=0 ) {
const reducer = ( result, value, key ) =>
_depth < depth && _isPlainObject( value )
? { ...result, ...flat( value, depth, _depth+1 ) }
: { ...result, [ key ]: value };
return _reduce( obj, reducer );
}
/**
* Equivalent of Array.filter(), but for objects.
* @private
*/
function _filter( obj, cb ) {
const reducer = ( result, value, key ) =>
cb( value, key, obj )
? { ...result, [key]: value }
: result;
return _reduce( obj, reducer );
}
/**
* Equivalent of Array.reduce(), but for objects.
* @private
*/
function _reduce( obj, cb, initial={} ) {
return Object.keys( obj ).reduce(
( result, key ) => cb( result, obj[ key ], key, obj ),
initial
);
}
/**
* @private
*/
function _isPlainObject( value ) {
if ( !value || Object.prototype.toString.call( value ) !== '[object Object]' )
return false;
const proto = Object.getPrototypeOf( value );
return proto === Object.prototype || proto === null;
}
And you would use it like this:
function Component( props ) {
const {
style,
visible, outline, color,
...restProps
} = props;
const styles = useStyle({
style,
{
display: visible ? 'block' : 'none',
outline: outline && '1px solid #f00',
'--color': color,
}
});
return (
<div
style={ styles }
{ ...restProps }
/>
)
}
Am I holding this wrong?
So first of all, I'm fairly new to Preact, and thus not super familiar with all of the best practices, gotchas, pitfalls, etcetera. For example, with regard to useStyle
, I went through the following stages:
- Don't worry about anything, Preact will take care of it!
- Shit, all the components are re-rendering all of the time! I need to
useMemo
! - Shit, I should not
useMemo
all of the time! - ....shouldn't I?
With that in mind, I came up with some possible downsides of useStyle
:
useMemo
comes at a cost
It's important to note that you should only use useMemo when you have expensive computations that need to be memoized. Using it for every value in your component can actually hurt performance, as useMemo itself has a small overhead.
LogRocket: When not to use the useMemo React Hook
Creating the styles
object is not a very expensive operation. Re-rendering the component (and it's children) could be, though. But it depends on how many children the component holds, how complex they are and how deep the tree is that Preact wants to re-render. If it's a node sitting at the very end of a tree (no children), it may be cheaper to just re-render the component and update the DOM. But if it's some Context
that causes it, Preact will re-render the entire tree and every optimization may count.
As a developer of a library of reusable components, I can't be sure where and how my components will be used, and that poses somewhat of a dilemma...
Dynamic dependencies & niceties come at a cost
Even if the overhead of useMemo
is worth the trade-off here, the code requires more operations (flat
, filter
, entries
), and those all add up as well. Especially because we're working with objects, so these helper functions are (probably) not as fast as their native Array equivalents.
(It probably doesn't help that I'm trying to be a good functional citizen, and re-creating objects all of the time for the sake of immutability)
Or...?
- What about Class Components? You can't use hooks there, so I guess we need something like
getDerivedStateFromProps
? - I may be trying to solve a problem where I could just do
npm i solution
1 - Or some issue/downside I haven't even thought of...
1 I don't really mind, though. Solving something yourself is good exercise, especially if you can then compare it to how others have done it.
Discuss!
This site doesn't have any forum functionality, so I think Mastodon will have to do for now: