DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four of our 24 shadcn components are imported by nothing, and that folder is the only code in the repo with no semicolons

src/components/ui holds 24 files and 2,314 lines of Nakodo's front end, and I did not write a single line of their style. The shadcn CLI did. Everything else in the repository is hand formatted, which makes that folder the one place where you can see a seam, and seams are worth auditing once in a while. So here is what three hours of reading it turned up: 338 lines nothing imports, a formatting difference you can count, two dependency choices that are newer than most tutorials, and one genuinely good bug fix living inside generated code.

The seam, in numbers

src/components/ui/*.tsx   24 files   2,314 lines     0 lines ending in a semicolon
src/lib/*.ts              48 files   4,201 lines   1,221 lines ending in a semicolon
Enter fullscreen mode Exit fullscreen mode

There is no Prettier in this repository, no .prettierrc, no format script, no formatter in CI. Code is wrapped by hand at about 120 columns. The CLI writes in its own house style, without semicolons, and nothing in the toolchain reconciles the two, so the style boundary follows the directory boundary exactly. ESLint runs over the folder (the ignore list is the build output, next-env.d.ts and reels/**), but ESLint is not a formatter and does not care.

If you want proof that nothing is formatting these files, checkbox.tsx has this in it:

<CheckIcon
/>
Enter fullscreen mode Exit fullscreen mode

That is a hand edit, almost certainly deleting a className from an element that used to span three lines, left as a two line self-closing tag. Any formatter would have joined it on save. It has been there through every commit since, it is harmless, and it is the clearest possible evidence of how these files are actually maintained: generated once, edited occasionally by a human, formatted by nobody.

The seam, from outside the repo

The folder leaves a fingerprint in the HTML. Every component root carries a data-slot, and Button and Switch add data-variant and data-size:

<Comp
  data-slot="button"
  data-variant={variant}
  data-size={size}
  className={cn(buttonVariants({ variant, size, className }))}
  {...props}
/>
Enter fullscreen mode Exit fullscreen mode

Which means you can audit our component usage from a terminal, with no access to the source:

$ curl -s https://nakodo.app/tools/engagement-rate-calculator | grep -o 'data-slot="[a-z-]*"' | sort | uniq -c
  10 data-slot="button"
  10 data-slot="input"
  10 data-slot="label"

$ curl -s https://nakodo.app/pricing | grep -o 'data-variant="[a-z-]*"' | sort | uniq -c
   2 data-variant="default"
   2 data-variant="ghost"
   2 data-variant="outline"
   1 data-variant="panel"
Enter fullscreen mode Exit fullscreen mode

I have come round to data attributes for this entirely. Classes are churn: a Tailwind class list is rewritten every time the design changes, so anything that selects on one is broken by a visual edit. data-slot="button" is a stable name for "this is the button component", which gives you a styling hook that survives a restyle, a test selector that does not care how the thing looks, and, as above, an inventory you can take from outside the app. The cost is a handful of bytes per element in the HTML, which I will pay.

Two dependency choices that are newer than the tutorials

Sixteen of the 24 files import from radix-ui, the single unified package, and zero import from @radix-ui/react-*:

import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { Slot } from "radix-ui"
Enter fullscreen mode Exit fullscreen mode

One dependency, one version to bump, instead of fourteen separate packages that can drift apart. If you are copying examples from a blog post written a year ago, this is the first thing that will look wrong.

The second one is smaller and funnier. The components do not import cn from the usual local helper, they import it from a package called cn:

import { cn } from "cn"
Enter fullscreen mode Exit fullscreen mode

That is shadcn's own package, "fast, small, compiled class-name merging for Tailwind CSS. Drop-in replacement for clsx + tailwind-merge". Neither clsx nor tailwind-merge appears in our package.json any more. And because components.json still declares the conventional alias:

"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui" }
Enter fullscreen mode Exit fullscreen mode

src/lib/utils.ts still exists, and it is one line long:

export { cn } from "cn"
Enter fullscreen mode Exit fullscreen mode

A shim with no logic, whose entire job is that the CLI's alias and the 48 files that import @/lib/utils keep resolving to the same function as the generated files use. I like this more than rewriting every import: the alias is a contract with a tool, and satisfying it costs one line.

The rest of components.json is worth printing, because it is the whole configuration of the folder:

{
  "style": "radix-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
  "iconLibrary": "lucide",
  "registries": {}
}
Enter fullscreen mode Exit fullscreen mode

"config": "" is the Tailwind 4 answer: there is no tailwind.config.js to point at, the theme is in the CSS. cssVariables: true is what makes the default look replaceable without touching these files, which is the only reason a design that looks nothing like stock shadcn can still take CLI updates.

The best code in the folder was added by hand

Three of the generated files, checkbox.tsx, switch.tsx and select.tsx, carry the same four line comment and the same one line fix:

<CheckboxPrimitive.Root
  // Radix resets to the value it mounted with when its form resets, and
  // React resets a form after its action runs. Remounting when the default
  // changes makes it reset to the current one, as a native input does, so
  // a value that was just saved stays shown.
  key={String(props.defaultChecked)}
  data-slot="checkbox"
Enter fullscreen mode Exit fullscreen mode

This one took a while to find, and it is a genuine React 19 plus Radix interaction rather than anybody's bug. Walk it through:

  1. A settings form posts to a Server Action. React resets the form once the action resolves.
  2. Resetting a form sends a reset event, and the Radix control responds by returning to the value it had when it mounted.
  3. But the action just saved a new value, and the server re-rendered the page with it as the new defaultChecked.
  4. So the user ticks a box, the save succeeds, and the box visibly unticks itself. The database is right and the screen is wrong, which is the worst combination available.

A native <input type="checkbox"> does not do this, because its reset target is whatever defaultChecked currently says. key={String(props.defaultChecked)} buys the same behaviour from a controlled-ish component in one line: when the default changes, the key changes, the subtree remounts, and the control's mount-time value is now the saved one. The alternative is to lift every switch into client state and reconcile it with the server value after each action, which is twenty lines per form and a new class of bug.

Eight of the 24 files have project comments in them like this. The other sixteen are untouched, which is a ratio I am happy with: the CLI's output is a starting point, and the edits that survive in it are the ones that encode something about our app rather than something about our taste.

The four files nothing imports

Here is the audit that prompted this post. Imports of each component, excluding the folder itself:

Component Import sites
button 57
input 20
label 20
textarea 10
select 8
table 8
sheet 6
badge 5
checkbox 5
switch 5
alert-dialog 3
card 3
dropdown-menu 3
tooltip 2
slider 2
alert 1
chart 1
popover 1
skeleton 1
sonner 1
dialog 0
scroll-area 0
separator 0
tabs 0

338 lines across four files that no code in the project references: dialog.tsx (168), tabs.tsx (89), scroll-area.tsx (54), separator.tsx (27).

None of them is a mystery. Confirmations go through one hook that uses AlertDialog, every side panel is a Sheet, the campaign page's tab bar is the app's own component with its own underline behaviour, and a separator in this design is a border-t on the thing below it. Each file was installed because it was in a list, and the thing it was going to do turned out to be done differently.

What is interesting is that nothing in the toolchain says a word about it. TypeScript does not care about a module nobody imports. ESLint flags unused variables inside a file, not unused files. There is no knip, no ts-prune, no bundler report in CI, and the dead files cost nothing at runtime because they are never in anybody's import graph, so there is no bundle-size signal either. An unimported module in a TypeScript project is invisible until somebody goes looking.

And when you do go looking, go carefully. A sibling project of mine published a post last week called "I nearly deleted four live components, because my audit grep only matched double quotes", which is exactly the trap available here, so I checked this three ways: a path grep for ui/<name> across the whole repo, a component-name grep for <Dialog, <Tabs, <Separator and <ScrollArea in every .tsx outside the folder, and a sweep for single-quoted imports anywhere in src, which returned exactly one hit: a SQL regex in a Postgres template string. The one name that turned up anywhere was <Tabs id={id} /> in the campaign layout, which is the app's own Tabs, not the generated one.

What I would want from the CLI

Not much, and one thing specifically: provenance in the file. Nothing in button.tsx records that it came from the radix-nova style of the registry at some version, or that four of its lines were later edited by a human. components.json knows the style name for the next install; the files themselves know nothing. A single generated header comment per file, with the registry item and the version it came from, would turn "is this stock or ours" from an archaeology question into a one line answer, and would make an update diff readable.

The model itself is right. These files are scaffolding I own, not a dependency I upgrade, and the proof is the bug fix above: a dependency could not have been patched like that, and a component library that owned these files would have made me wait for a release.

Next commit deletes four files and 338 lines. The pages they were never used on are live: the engagement rate calculator is 10 inputs, 10 labels and 10 buttons out of that folder, and the pricing page is where you can count the button variants for yourself.

Top comments (0)