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:

  1. Don't worry about anything, Preact will take care of it!
  2. Shit, all the components are re-rendering all of the time! I need to useMemo!
  3. Shit, I should not useMemo all of the time!
  4. ....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:

👉 AIHTW: The useStyle hook