VOOZH about

URL: https://dev.to/oleks_samurai/dry-is-not-for-ui-9eb

⇱ DRY is not for UI - DEV Community


When we talk about UI, we almost always start by thinking about small components we can reuse across the frontend. And of course we can't reuse every component. But where exactly does that border of reusability lie — and how do we decide which components are worth reusing and which aren't?

To answer that, I'll first walk you through a problem we ran into on our product, and then show you how we solved it.

The problem: one browsing experience, many pages

On our product we show senders a list of products that can become a gift for a recipient, and they can browse that list with a variety of filters. There are two main types of product list: a plain list of products, and products grouped into collections for a specific occasion.

This list shows up in a lot of places. A sender sees it on the main browsing page; when sending a single gift; when sending a collection of gifts; when automating sends for recurring occasions (birthday, anniversary, and so on); and when building their own collection. That's at least four different pages or modals where the user should get the same product-browsing experience — search and filters included.

Sooner or later, though, "the same experience" starts to crack. Design wants to add an element on one flow only. Filters need to behave differently depending on the page. A handler we share across all of them now has to return a different response depending on which request it's serving — same function, different output per page. Each of these little exceptions leaks a boolean flag into our reusable widget, and conditional branching and conditional rendering slowly take over and start defining the component's behavior.

And then the most painful problem shows up: a change on one page breaks the tests, logic, or UI on a completely different page — one that, from a domain perspective, has nothing to do with it. That is exactly not how we want to maintain our app.

So where's the real boundary?

At the same time, we couldn't get the idea of reusing page layouts across similar flows out of our heads. We wanted both: shared building blocks and shared layouts, without the cross-page breakage.

The thing that kept biting us wasn't reuse itself — it was what we were reusing. Every painful case had the same shape: we were reusing a component that already carried a piece of logic inside it. So the rule we landed on is blunt:

Don't reuse a component that has even one piece of logic baked into it.

Reusable components should be purely presentational. The components that put them to work — the ones that wire in data, side effects, and page-specific behavior — aren't things to reuse. They're use-cases of the reusable UI. A page is a use-case. A modal is a use-case. They're allowed to differ, because differing is their whole job.

The moment you start reusing presentational components that have logic in them, the problems from the previous section begin. That's the border.

So we split the system in two: a library of pure, reusable UI components, and everything else built on top as use-cases. Here's how we ship that library.

How we ship our design system the shadcn way (without shipping shadcn)

We borrowed two things from shadcn — and only two:

  1. The API shape. Our components mirror shadcn/MUI prop conventions (variant, size, CVA variant maps), so they feel familiar to any React developer.
  2. The distribution mechanism. The shadcn CLI doesn't care whose components it installs — it just reads a JSON file that follows the registry-item schema. That's a protocol, and anyone can implement it.

Under the hood, nothing is actually shadcn: our components are built on Base UI (not Radix), styled with Tailwind v4 + CVA against our own OKLCH design tokens.

The pipeline

registry/acmeui/acme-button.tsx ← component source (Base UI + CVA + tokens)
registry/registry-ui.ts ← manifest: name, deps, registryDependencies
 ↓ npm run build:registry
public/r/acme-button.json ← self-contained installable unit

The build script reads the manifest and emits one JSON file per component. The key trick: the entire TSX source is embedded as a string in files[].content, alongside npm dependencies and registryDependencies (other registry items, like our utils). The JSON is fully self-contained — it can be served as a static file, fetched from a URL, or read straight off disk.

Installing

Our docs site (Next.js) serves public/r/ statically. A consumer app declares the registry once in its components.json:

"registries":{"@acmeui":"https://acmeui.com/r/{name}.json"}

and then:

npx shadcn@latest add @acmeui/acme-button

The CLI fetches the JSON, recursively resolves registryDependencies, installs the npm deps, writes the source into the folder mapped by the consumer's aliases.ui, and rewrites imports to match. A local path works too — handy for testing the artifact before publishing:

npx shadcn@latest add ./apps/acmeui/public/r/acme-button.json

One twist on ownership

Vanilla shadcn says: "copy it, it's yours." We flipped that. Installed files carry a DO NOT MODIFY banner and are treated as managed artifacts. Changes happen in the design-system repo; consumers re-sync with --overwrite. You get shadcn's zero-runtime, copy-the-source distribution — with a single source of truth.

Reusable layouts, not reusable logic

On top of that, our design team can now build full page examples in Storybook using the components straight from the registry. That gives us reusable page layouts with code examples that are easy to copy, paste, and then wire your own logic into.

Notice what we didn't do: we didn't ship a "smart" page component with a flag for every flow. We shipped a layout you copy and own. Each page takes the shared presentational skeleton and becomes its own use-case — with its own logic, its own side effects, its own tests. A change in one flow can't reach into another, because there's nothing logical shared between them to break.

DRY is good — until it breaks Single Responsibility

So here's what I actually want to emphasize. DRY is a great principle to follow — but only when the components you're reusing aren't breaking another SOLID principle: S, single responsibility. The moment a "reusable" component takes on logic and presentation, it stops having a single responsibility, and from that point on following DRY becomes a headache for the maintainability, scalability, and predictability of the whole system.

SOLID pays off when you follow all of it at once. Cherry-pick a couple of letters and ignore their neighbours in the SOLID family, and you get exactly the kind of mess I described above. DRY without S is one of those traps.