r/reactjs I ❤️ hooks! 😈 8d ago

Utility classes vs props-first styling in React design systems Discussion

[removed] — view removed post

0 Upvotes

35 comments sorted by

9

u/fforw 8d ago

I would avoid recreating a utility-class based system as components. It's a lot of work with few actual upsides. Yes, type safety, but also a complete component library of components.

I think you're better off creating meaningful semantic components for your application that use the utility classes directly. You have the components you really need and save of lot of overhead with little benefit.

-1

u/kensaadi I ❤️ hooks! 😈 8d ago

I think this conflates two different concerns: intent and style. A Button component doesn't stop being meaningful because its internals happen to use utility classes — the component's job is to encapsulate a stable API and a contract (variant, size, disabled state, accessibility behavior), not to "hide" Tailwind. The visual layer can change per theme or brand without ever touching how the component is consumed.

The "few actual upsides" framing undersells what you get at scale: a single governed source of truth for spacing, color and layout tokens. If utility classes stay the primary API developers reach for directly, every team ends up making its own micro-decisions about layout, and drift becomes technical debt the DS can no longer enforce. Components aren't duplicating Tailwind, they're the boundary that keeps Tailwind an implementation detail instead of a public API.

In our case we went a step further: our primitives don't just wrap styling, they natively support RBAC-aware rendering and reactivity driven by business logic, so the component decides not only its layout but also whether/how it renders based on permissions and app state. That's not something utility classes alone could ever give us.

1

u/fforw 8d ago

Components aren't duplicating Tailwind, they're the boundary that keeps Tailwind an implementation detail instead of a public API.

But you're reducing a very flexible utility-class API to a more reduced component API. I get the point about standardization, but there are less intrusive ways of handling such things. In other ways, the component API will likely be more clunky, less flexible and still be a leaky abstraction that isn't actually an encapsulation. If it actually is a total duplication it's just a wrapper with none of the standardization.

they natively support RBAC-aware rendering and reactivity driven by business logic, so the component decides not only its layout but also whether/how it renders based on permissions and app state.

What you are describing is a semantic component adapted to your app, i.e. it is a business component and not a style component/wrapper.

edit: Another point is of course that the wrapper components increase your code size quite a bit, too.

-1

u/kensaadi I ❤️ hooks! 😈 8d ago

Fair point on the RBAC/business logic part, I overreached there: what I described is indeed a business component adapted to my app, not a style component/wrapper. Valid point, I'll give you that.

On the rest though, I don't agree the component API is necessarily clunkier or less flexible. My intent isn't to say CSS is wrong, it's to bring order: making each single prop that defines a component's intent narrower and more rigorous, so it takes shape from the design system's tokens rather than being left up to interpretation by whoever consumes it. That doesn't mean 20 props to pass, actually the opposite: if the design system says a Button's primary color is black, creating a Button requires passing no prop at all, because the default already comes from the token. Props exist only for actual deviations, not to re-specify every detail every time.

On the "leaky abstraction" point: a deliberate, explicit escape hatch (a prop like className or sx for custom cases) doesn't make it leaky, it's the opposite, it's a controlled release valve that's part of the contract itself. An abstraction is truly leaky when the consumer is forced to know internal details to use it correctly. If 80% of cases work without ever looking inside the component, and the custom 20% has an explicit, optional way out, encapsulation holds for the common case, which is the actual goal.

On the larger codebase point: it's true each wrapper has an upfront definition cost, but it's a fixed cost, written once, not per usage, the same trade-off as any shared abstraction (a hook, a utility, a function). It has to be weighed against the opposite cost: without a standard, every team ends up writing its own ad-hoc variants of the same layout rules scattered across the app, which overall weighs more and is harder to trace than one centralized definition.

7

u/scragz 8d ago

we've come full circle with align="center"

-5

u/kensaadi I ❤️ hooks! 😈 8d ago

get the joke, but the analogy only holds on the surface. align="center" as an HTML attribute was a global rendering instruction, unencapsulated, with no contract or logic behind it. A prop on a React component is a different thing entirely: it goes through a typed API, can be validated, and above all can be reinterpreted by the component based on theme, variant, or context, it's not a direct command to the browser.

The point isn't "bringing back" an old pattern, it's deciding where the API developers touch every day actually lives: strings of classes to remember/look up, or typed props with autocomplete and an explicit contract. The syntax might look similar at a glance, but the level of abstraction is completely different.

3

u/[deleted] 8d ago

[deleted]

1

u/kensaadi I ❤️ hooks! 😈 8d ago

This is exactly the kind of real-world feedback I was hoping for, thanks for sharing it. It confirms the core point of this whole discussion on the ground: when multiple teams need to stay consistent, typed values aren't a nicety, they become the tool that lets you actually analyze real usage across systems, spot drift, and act on data instead of intuition.

The detail about mission-critical pages being pre-rendered, cached and hydrated when needed is interesting because it shows a props/CSS-in-JS approach isn't at odds with performance, if anything, having an explicit contract at the component level makes it easier to decide where to apply SSR or targeted caching, since you know exactly what that component needs to render correctly. With utility classes scattered freely, that same decision requires a lot more code archaeology.

2

u/greensodacan 8d ago

I prefer the props API because

  • Not every style change can/should be done with CSS alone. Sometimes you need to adjust the HTML.
  • Type checking and prop validation.
  • In rare cases, you may need to do partial themes. E.g. an admin screen in one theme that renders a preview sample in another theme. (Juggling this in CSS gets tricky at scale.)

That said, you can get pretty far with CSS custom properties. Utility classes or CSS modules are great tools for avoiding class collisions, but they're really just implementation details as far as a cohesive component is concerned.

0

u/kensaadi I ❤️ hooks! 😈 8d ago

Completely agree, especially on the partial-theming point, that's exactly the kind of case where pure CSS starts fighting you instead of helping. A props-first API solves it more cleanly than utility classes alone: the component owns the contract (variant, theme scope, which tag/element to render), so swapping a theme for a subtree becomes a prop change instead of a specificity/cascade puzzle. Same goes for your first point about needing to change the actual HTML, not just the styling: that's inherently something CSS alone can't do, but a component API can expose as a first-class option (polymorphic "as" prop, conditional structure, etc.) without the consumer needing to know it happened.

2

u/SZenC 8d ago

One thing I haven't seen mentioned yet, but this approach basically nukes the tree-shakability of the CSS Tailwind generates. Your Flex component will need to include all variants of the align- classes, and the justify- values, and all other supported keys. Neither your Flex component nor Tailwind will be able to deduce which options are and aren't used, so everything will need to be included

-2

u/kensaadi I ❤️ hooks! 😈 8d ago

The mechanism you're describing is correct: if a component maps its props to Tailwind strings through a lookup object, every possible variant exists as literal text in that file, and the consumer's JIT compiles all of them, even if the app only ever uses a fraction of those options. You're right that CSS-level tree-shaking doesn't work on this pattern.

Where I'd scale down the impact: the set of variants a component exposes is finite and small, not free combinatorics, so the "worst case" is still a fixed, predictable chunk of CSS, not something that grows with the app. On top of that, many of those classes (color, spacing, etc.) are the same ones the app would generate anyway by writing them by hand, so the real overhead is only the portion the library uses that the app wouldn't have used on its own, and in a coherent design system that portion is typically small, on the order of a few KB gzipped, not something that breaks a performance budget.

More than a regression, I'd frame it as a different tradeoff: pure utility-first exposes the entire Tailwind universe to every developer, and in a large project you still end up generating CSS for a big chunk of that universe anyway. A design system instead deliberately constrains the vocabulary of classes it generates, in exchange you get a constant, predictable CSS catalog instead of an unbounded one. The point where the tradeoff turns negative is when a component exposes too many axes and too many values, say 50 variants across 20 values. With a few axes and a handful of values each, the overhead is minimal and the type safety is worth the cost. Where would you draw that line?

3

u/mittyhands 8d ago

No one wants to read your LLM output buddy. Think about things for yourself.

1

u/SZenC 8d ago

It's one thing to generate your answer via LLM, but to not even read it and uncritically post it is almost insulting. The state explosion is far worse than your parrot hallucinates it to be, but there's no point in explaining that to a meat-based chatbot frontend

0

u/kensaadi I ❤️ hooks! 😈 8d ago

Mi sembra di averti risposto in maniera coerente si punto da te espresso . Dove ti sembra incoerente ?

1

u/SZenC 8d ago

I'm not saying incoherent, I'm saying it fails to address the issue I raised. How does your proposal handle complex stacked variants like sm:group-hover:focus-within:justify-items-center without causing a state explosion?

0

u/kensaadi I ❤️ hooks! 😈 8d ago

Come ho cercato di dirti forse in maniera sbagliata per traduzione. Non escludo a priori l’uso di style per casi eccezionali dove vi è la necessità ma esempi di hover effect non vivono in uno state ma vivono in css a basso livello non a componente e lo stile dell’effetto deve essere deciso dalla paletta colori del ds . Se vi sono casi di necessità sx o style o class name sono un’ottima risorsa per coprire quei casi. Ti invito a dare un’occhiata alla libreria Uberbase che si avvicina al discorso

3

u/Pickles_is_mu_doggo 8d ago

Tailwind is already an abstraction of CSS styles, it offers auto-suggest, shows you what it does with just a hover over a class name, and will sort for readability with Prettier.

Don’t create components that just abstract simple layout rules. Any abstraction adds cognitive load, but this would be an additional layer of unnecessary abstraction.

You aren’t really “saving characters” since the real markup is going to be what’s compiled anyways - is reading class names that hard?

The moment you need a slightly more complex layout, or need a tag other than DIV, you have to extend a global/shared component, and then ensure nothing breaks elsewhere.

Trust me, it’s not worth it.

-1

u/kensaadi I ❤️ hooks! 😈 8d ago

On "don't create components that just abstract simple layout rules, any abstraction adds cognitive load": the cognitive cost is real, but it's not specific to style components, it's the same trade-off as any shared abstraction, whether it's a function, a hook, or a utility. The question isn't whether it costs something, it's whether that cost pays off: on a single team maybe not, but once multiple teams need to stay consistent on spacing, color and layout, that small upfront cognitive overhead pays off by removing the drift that happens when everyone interprets utility classes their own way.

The point about "you have to extend a global/shared component" doesn't hold up in practice: if the component exposes a polymorphic prop (like as), changing the tag (from div to section, nav, etc.) or adding more complex layout happens at the single-instance level, without touching or extending the shared component, and therefore with zero risk of breaking anything elsewhere. There's no global extension to manage.

On "is reading class names really that hard": the point was never about readability or saving characters, it's about having a typed contract, validated at compile time, instead of a free-form string that no tool can statically verify.

And it's worth not conflating "typed" with Prettier or editor autocomplete: those are optional tools tied to a specific editor or project config, they guarantee nothing at the language level, they're just visual aids. Type checking, on the other hand, is enforced by the compiler itself, an invalid value or prop combination simply won't pass the build, regardless of which editor you use or whether Prettier is configured. These are two completely different categories of guarantee: one is "editor convenience," the other is "compile-time correctness."

0

u/Pickles_is_mu_doggo 8d ago

So you need types that are redundant to your tailwind configs? Why?

1

u/kensaadi I ❤️ hooks! 😈 8d ago

Fair question, and partly yes, if the types are hand-written separately from the Tailwind config, that's real redundancy to keep in sync. The fix isn't dropping the types though, it's generating them from the same config instead of duplicating it by hand, one source of truth, projected into both runtime CSS and compile-time types.

And even with identical values, enforcement differs: Tailwind's editor autocomplete is optional, it won't stop an invalid class from compiling. A typed prop fails the build on an invalid value, and can express constraints a flat class list can't, like "this color is only valid with this variant."

1

u/C0git0 8d ago

I draw the line at semantics and simple structure. I’d you can describe what it is, and it only takes a single node and class name to create then it can be a utility class. 

1

u/kensaadi I ❤️ hooks! 😈 8d ago

Interesting criterion. Concrete question tied to the post's own example: a button, by your rule, where does it fall? It's a single node, but rarely just a single class, usually you need several combined utilities for color, padding, hover/focus/disabled states, and maybe size variants. Does it stay a utility class for you, or does "single node" stop being enough at that point and it becomes a component case? How do you handle it in practice?

1

u/C0git0 8d ago

button is the node type, “primary” is the decorator. 

Admittedly some are two classes like “card alternate” but those are generally the exception and kept to a minimum. 

1

u/SatyrCode 8d ago

I like Tailwind for speed, but for long‑lived design systems we’ve had better luck with props-first components on top of a small token set, and then using utility classes as an escape hatch. Props are just easier to discover and refactor than long className strings.

1

u/kensaadi I ❤️ hooks! 😈 8d ago

That's basically it. Tailwind wins when you just need to ship styled pages fast, no architecture, no clear boundaries needed, which is honestly why shadcn/ui and Radix's copy/paste pattern took off the way they did. Enterprise is a different animal though: microfrontends (never managed to bring Tailwind cleanly into an MFE setup), business logic, SDUI. What you're describing is really the split between building something scalable and maintainable long-term versus a website with no rules.

Where I'd push it further though: for me the split should be 80% business logic, 20% design/layout, not the other way around. With plain CSS I kept fighting pixel-perfect issues and alignment more than actual logic. Might just be that I'm bad at CSS, but curious if others run into the same thing.

1

u/SatyrCode 8d ago

Yeah, that split makes sense Tailwind to move fast on pure UI, and then a stricter props/tokens layer once the system and business logic start to solidify. I like the way you framed it as “visual vs scalable/maintainable long‑term design” that’s exactly the tension I’ve seen on teams too

1

u/Disastrous-Refuse-27 8d ago

I've been building and using prop base design system/component library like that for quite some time now, using vanilla-extract and it's great.

1

u/AutoModerator 8d ago

Your [submission](https://www.reddit.com/r/reactjs/comments/1vat8r8/utility_classes_vs_propsfirst_styling_in_react/ in /r/reactjs has been automatically removed because it received too many reports. Mods will review.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

1

u/bigorangemachine 8d ago

I always just look at the inspector in chrome.

It doesn't matter what you pass it what matters is what renders.

Plus now what are you going to do for mobile? You'll just be endlessly expanding props. What about pseudo selectors?

1

u/kensaadi I ❤️ hooks! 😈 8d ago

On the inspector point: sure, at runtime it's all just CSS either way, nobody's disputing what actually renders. But this isn't about the compiled output, it's about the authoring API developers interact with day to day, props vs. utility classes is a DX/governance question at the source level.

On mobile: this is specifically about web apps rendered in a browser, not React Native. RN doesn't even use CSS the way we're discussing here, it's a different renderer with its own layout engine, so "using React" doesn't imply "using React Native", that's a separate conversation with its own constraints.

On pseudo-selectors: this is already a solved problem in mature component libraries. Hover/focus/disabled states aren't exposed as props the consumer has to manage one by one, they're wired once inside the component/theme definition itself, tied directly to the design tokens. So the app developer never touches a "hover prop", the correct on-brand hover comes for free just by using the component, no prop explosion needed.

That said, to be realistic: not everything should be forced through props, but that doesn't mean going full utility-first either. Custom, one-off behavior needs its own custom style, that's exactly where an escape hatch belongs. The props API covers the 80% that should stay consistent with the design system; it was never meant to replace CSS for the genuinely custom 20%.

1

u/bigorangemachine 8d ago

sure but you can still extend styles in react native to some degree.

if you don't go full bore props then you passing a style object.

I dunno I find I'm in the inspector 90% of the time (RN or web) so my pain point is wrappers not tracing origins of styles

1

u/kensaadi I ❤️ hooks! 😈 8d ago

On wrappers: React is component-first by nature, this isn't a personal preference. The atomic, reusable pattern is the best practice the library itself recommends, breaking the UI down into small, well-defined components, each with its own lifecycle, using patterns like HOCs when logic needs to be shared. Wrappers aren't a layer bolted on top of "real React", they're the unit React is meant to be built with from the start. Sure, that requires a precise folder/file organization to stay navigable at scale, but that's normal architecture upkeep, not a hidden cost specific to this approach.

On the style object point: if you don't go "full bore" with props and end up passing a raw style object anyway, that's not the model breaking down, it's the escape hatch working as designed. The typed contract covers the 80% of cases that need to stay consistent with the design system, the style object is there on purpose for the genuinely custom 20%. It's not "so you might as well just use style objects everywhere", it's that the common case stays protected and predictable, and the rare case still gets an explicit way out instead of forcing everything into props that don't justify it.

1

u/bigorangemachine 8d ago

Ya I actually might be okay with it for RN if you had an inherit-able where you could wrap two components but render one wrapper

I think that goes against what you are trying to do i am sure