DEV Community

OmniDev
OmniDev

Posted on

Building Fluentic Style: Why `combineStyle` Became More Than A Merge Helper

This is part of my Building Fluentic Style series, where I’m writing down the design decisions, tradeoffs, and small surprises from building Fluentic Style.

combineStyle is one of those APIs that looks smaller than the problem behind it.

const css = combineStyle(
  cardStyles,
  bindScope(cardStyles.root, props.theme),
);
Enter fullscreen mode Exit fullscreen mode

At first glance, it looks like a merge helper.

But I eventually stopped thinking about it that way.

To me, combineStyle became the place where a component’s own styles meet styles passed from outside.

The problem was not “how do I merge two things?”

The real problem was:

How do component styles stay extendable without making JSX turn into a pile of manual style merging?

That is the part I kept running into.

The Simple Version Is Always Fine

A component usually starts clean.

Maybe you have a Card.

import { style } from '@fluentic/style';

const cardStyles = {
  root: style({
    padding: 24,
    borderRadius: 12,
    backgroundColor: 'white',
  }),

  title: style({
    fontSize: 20,
    fontWeight: 700,
  }),

  body: style({
    color: '#475569',
  }),
};

type CardProps = {
  title: string;
  body: string;
};

export function Card(props: CardProps) {
  return (
    <article css={cardStyles.root}>
      <h2 css={cardStyles.title}>{props.title}</h2>
      <p css={cardStyles.body}>{props.body}</p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the happy path: the markup stays readable, the styles are nearby, and for many components this is enough.

The JSX mostly tells me what the component renders.

That is the feeling I want to keep.

Then The Component Becomes Reusable

At some point, the component needs more styling inputs.

Maybe one page needs a featured card.

Maybe another page needs a compact card.

Maybe a design system wants to expose a few component parts so product code can style them.

With class names, I usually end up adding something like this:

type CardProps = {
  title: string;
  body: string;
  classes?: {
    root?: string;
    title?: string;
    body?: string;
  };
};
Enter fullscreen mode Exit fullscreen mode

Then the component starts stitching its own classes together with incoming classes:

export function Card(props: CardProps) {
  return (
    <article className={cx(styles.root, props.classes?.root)}>
      <h2 className={cx(styles.title, props.classes?.title)}>
        {props.title}
      </h2>

      <p className={cx(styles.body, props.classes?.body)}>
        {props.body}
      </p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works.

I have written this kind of code many times.

Then variants arrive:

export function Card(props: CardProps) {
  return (
    <article
      className={cx(
        styles.root,
        props.compact && styles.compactRoot,
        props.featured && styles.featuredRoot,
        props.classes?.root,
      )}
    >
      <h2
        className={cx(
          styles.title,
          props.compact && styles.compactTitle,
          props.featured && styles.featuredTitle,
          props.classes?.title,
        )}
      >
        {props.title}
      </h2>

      <p
        className={cx(
          styles.body,
          props.featured && styles.featuredBody,
          props.classes?.body,
        )}
      >
        {props.body}
      </p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Still normal frontend code.

Still understandable.

But the JSX is no longer only showing the structure of the component.

It is also carrying the styling merge rules.

And the more parts the component has, the more that merge logic spreads through the markup.

That is the part I wanted Fluentic to avoid.

The JSX should mostly express the data, the elements, and the component structure.

Styling composition should have a clear place instead of leaking into every className or css prop.

The First Idea Was A Hook

At one point, I thought Fluentic might need something hook-like.

function Card(props: CardProps) {
  const css = useStyles(cardStyles, props.theme);

  return (
    <article css={css.root}>
      <h2 css={css.title}>{props.title}</h2>
      <p css={css.body}>{props.body}</p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

For React, this feels familiar.

A hook gives you a place to prepare styles before render.

A hook can read props.

A hook can memoize.

So the idea is not strange.

But the more Fluentic grew, the less I wanted the style resolver to be a hook.

A hook could still call a shared cache internally, so this was not only a performance argument.

The bigger issue was that this operation does not really need React state, React lifecycle, or React component identity.

Style composition depends on the style objects and scopes you pass in.

Making the main API a hook would make React part of something that does not actually require React.

That felt like the wrong direction, especially once Fluentic started moving toward Next.js server/client support, SolidJS support, and compiler-transformed JSX.

So combineStyle became a plain function.

const css = combineStyle(cardStyles, ...inputs);
Enter fullscreen mode Exit fullscreen mode

No hook.

No component instance required.

No React lifecycle required.

Just styles in, final styles out.

The Fluentic Version

In Fluentic, when a component wants to expose styleable parts, those parts can become slots.

import {
  bindScope,
  combineStyle,
  style,
  type StyleProp,
  type StyleTheme,
} from '@fluentic/style';

const cardStyles = {
  root: style.slot({
    padding: 24,
    borderRadius: 12,
    backgroundColor: 'white',
  }),

  title: style.slot({
    fontSize: 20,
    fontWeight: 700,
  }),

  body: style.slot({
    color: '#475569',
  }),
};

type CardProps = {
  title: string;
  body: string;
  compact?: boolean;
  featured?: boolean;
  css?: StyleProp;
  theme?: StyleTheme;
};
Enter fullscreen mode Exit fullscreen mode

A slot still feels like writing a normal style.

The extra part is that another style can point to it later.

Outside styles can provide changes for root, title, body, or any other public slot the component exposes.

Those grouped changes are scopes:

const compactCard = style.scope([
  cardStyles.root({
    padding: 16,
  }),

  cardStyles.title({
    fontSize: 16,
  }),
]);

const featuredCard = style.scope([
  cardStyles.root({
    backgroundColor: '#0f172a',
    color: 'white',
  }),

  cardStyles.title({
    color: '#e0f2fe',
  }),

  cardStyles.body({
    color: '#cbd5e1',
  }),
]);
Enter fullscreen mode Exit fullscreen mode

Then the component prepares the final styles in one place:

export function Card(props: CardProps) {
  const css = combineStyle(
    cardStyles,
    bindScope(
      cardStyles.root,
      props.compact && compactCard,
      props.featured && featuredCard,
      props.theme,
    ),
  );

  return (
    <article css={[css.root, props.css]}>
      <h2 css={css.title}>{props.title}</h2>
      <p css={css.body}>{props.body}</p>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the pattern I wanted.

The component has one style resolution point.

The JSX receives the final styles.

The root still accepts props.css for direct root element styling.

The theme prop accepts styles for component slots.

The component still decides where those styles attach.

<Card
  title="Revenue"
  body="$42,300"
  featured
  theme={dashboardCardTheme}
/>
Enter fullscreen mode Exit fullscreen mode

A small note on the names here:

StyleTheme is the type Fluentic uses for styles passed into component slots.

The prop does not have to be called theme, but that is the convention Fluentic docs use.

bindScope(cardStyles.root, props.theme) tells Fluentic where those incoming scopes should attach. For a card, that target is usually the root slot.

Why bindScope Matters Here

Scopes need a target slot.

You can call each scope directly:

const css = combineStyle(
  cardStyles,
  props.compact && compactCard(cardStyles.root),
  props.featured && featuredCard(cardStyles.root),
  props.theme?.(cardStyles.root),
);
Enter fullscreen mode Exit fullscreen mode

That works for simple cases.

But once you have multiple scopes, repeating the same target gets noisy.

bindScope lets the component say the target once:

const css = combineStyle(
  cardStyles,
  bindScope(
    cardStyles.root,
    props.compact && compactCard,
    props.featured && featuredCard,
    props.theme,
  ),
);
Enter fullscreen mode Exit fullscreen mode

This is also why props.theme usually stays last.

The component can apply its own state or variant scopes first, then let external component styling come after.

That keeps the override order predictable.

Base styles first.

Component variants next.

Styles passed from outside last.

And if the root element also accepts a direct css prop, that can stay at the root element:

<article css={[css.root, props.css]}>
Enter fullscreen mode Exit fullscreen mode

So there are two related extension points:

theme -> styles provided to component slots
css   -> styles attached to the root element
Enter fullscreen mode Exit fullscreen mode

That distinction keeps the component API flexible without making every element carry a long merge expression.

The Difference In JSX

Without this kind of resolution point, the component often ends up with a style merge matrix:

<article
  className={cx(
    styles.root,
    props.compact && styles.compactRoot,
    props.featured && styles.featuredRoot,
    props.classes?.root,
  )}
>
  <h2
    className={cx(
      styles.title,
      props.compact && styles.compactTitle,
      props.featured && styles.featuredTitle,
      props.classes?.title,
    )}
  >
    {props.title}
  </h2>

  <p
    className={cx(
      styles.body,
      props.featured && styles.featuredBody,
      props.classes?.body,
    )}
  >
    {props.body}
  </p>
</article>
Enter fullscreen mode Exit fullscreen mode

With combineStyle, the composition moves up to one place:

const css = combineStyle(
  cardStyles,
  bindScope(
    cardStyles.root,
    props.compact && compactCard,
    props.featured && featuredCard,
    props.theme,
  ),
);
Enter fullscreen mode Exit fullscreen mode

Then the JSX stays closer to the component structure:

return (
  <article css={[css.root, props.css]}>
    <h2 css={css.title}>{props.title}</h2>
    <p css={css.body}>{props.body}</p>
  </article>
);
Enter fullscreen mode Exit fullscreen mode

This is a small difference in code, but it changes the maintenance feeling.

The style composition has a home.

The markup does not need to repeat the same merge pattern for every part.

The Cache Question

The hard part is that hooks make caching feel obvious.

With a hook, you can imagine useMemo.

You can imagine instance-local caching.

You can make the work feel tied to React render behavior.

A plain function does not automatically give you that comfort.

So combineStyle had to answer:

If this is just a function, how does it avoid doing too much work?

The answer in Fluentic is to reuse work based on the style objects and the path used to resolve them.

Most component styles are stable values:

const cardStyles = {
  root: style.slot({ padding: 24 }),
  title: style.slot({ fontWeight: 700 }),
};
Enter fullscreen mode Exit fullscreen mode

Scopes are often stable too:

const compactCard = style.scope([
  cardStyles.root({ padding: 16 }),
  cardStyles.title({ fontSize: 16 }),
]);
Enter fullscreen mode Exit fullscreen mode

So when many component instances use the same styles and scopes, Fluentic can reuse the work instead of treating each instance like a completely new styling case.

combineStyle(
  cardStyles,
  bindScope(cardStyles.root, compactCard),
);
Enter fullscreen mode Exit fullscreen mode

The useful question is not only:

What happened in this component instance?

It is also:

Have these styles already been resolved this way?

That fits Fluentic better than making the API a React hook.

A hook can still be useful in some styling systems, but combineStyle does not need to be one.

Production Should Not Pay For Dev-Time Work

There is another important piece here.

combineStyle should not make every render feel like Fluentic is compiling styles again from scratch.

In development, Fluentic needs to understand your style code, keep debug names, generate sourcemaps, and make DevTools useful.

That work is valuable while building.

But it should not become the normal production cost.

In production, Fluentic’s compiler and bundler plugin can turn style chains into extracted CSS and prepared JavaScript output.

The browser still handles the parts that only become known while the app is rendering, like scopes chosen by props or state, theme values, and prop-driven style values.

But the heavier work of reading the style chain and generating CSS belongs to the build.

So the goal for combineStyle is practical:

Keep component style composition predictable and fast enough that component authors do not have to think about it.

It should be easy to prepare final styles in one place and keep the JSX readable.

Why This Became The Handoff Point

At first, combineStyle looked like a utility.

Now I see it as the handoff point inside a component.

It is where the component gathers:

  • its default styles
  • its slots
  • variant styles
  • state styles
  • styles passed from outside
  • token/theme overrides
  • root-level css

Then JSX receives the final styles.

That matters because Fluentic is trying to support more than one styling layer.

Quick element styling:

<div css={{ padding: 16 }} />
Enter fullscreen mode Exit fullscreen mode

Component part styling:

<Card theme={featuredCardTheme} />
Enter fullscreen mode Exit fullscreen mode

Root element styling:

<Card css={pageCardSpacing} />
Enter fullscreen mode Exit fullscreen mode

App-level theming:

<main css={[appRoot, darkTheme]}>
  <Card theme={dashboardCardTheme} />
</main>
Enter fullscreen mode Exit fullscreen mode

And the component handoff point:

const css = combineStyle(
  cardStyles,
  bindScope(cardStyles.root, props.theme),
);
Enter fullscreen mode Exit fullscreen mode

That is the reason combineStyle exists.

Not because merging styles is difficult by itself.

It exists so a component has one explicit place where its own styles meet styles passed from outside, while its JSX stays focused on structure.

Docs

Related docs:

Fluentic Style is still new and currently in beta.

I am still looking for early users and feedback, especially from people building real component systems where styling needs to go beyond one element and one class name.

Top comments (0)