2026

June 16, 2026

Eww, That Stinks! Introducing react-stinky

A code smell is the React equivalent of milk that turned over the weekend: it still pours, but something is off. react-stinky is an agent skill that walks your whole component, hook, or module, names the cost of each smell, proposes a concrete fix with a source link, and (the part that makes it usable) knows when to keep its mouth shut. Here is what it sniffs for, how it rates the funk, and why the list of things it refuses to flag matters more than the list of things it does.

S
Sascha Becker
Author

11 min read

Eww, That Stinks! Introducing react-stinky

Eww, That Stinks! Introducing react-stinky

ARE YOU TIRED of opening a component and getting hit by the funk? Does your <Stack> have an onClick that no keyboard user can reach? Are your boolean props breeding like rabbits, isSmall next to isLarge next to isError next to isLoading, until nobody can tell which combination is even legal? Folks, we have ALL been there. Your code shipped clean on Friday. By Monday it REEKS. But what if there were a way to sniff out the funk before your reviewer does? Introducing react-stinky, the code smell detector that walks your whole component, holds its nose, and tells you exactly what died and how to bury it. It slices. It dices. It cites the React docs. But wait, there's more.

Okay. Infomercial voice off.

react-stinky is a new agent skill in my open set, and under the As-Seen-On-TV paint it is a serious tool built around one constraint: a smell detector is only as good as the things it stays quiet about. Here is what it does, what it refuses to do, and why that second list is the part that makes it worth keeping.

What it actually sniffs for

react-stinky is a holistic code smell detector for React and TypeScript. The word that earns its keep is holistic. A normal lint rule looks at one line in isolation. react-stinky reads the whole component, the hook, the module, and asks the questions a careful reviewer asks. Is this state derivable from props instead of stored? Is this effect doing a job that belongs in an event handler? Can a keyboard user actually trigger this thing? Does this prop shape allow a state that should be impossible?

For every smell it finds, it does three things a linter usually does not:

  1. Names the cost. Not "avoid this" but "an extra render and a state value that can drift from its inputs." You get the actual price so you can decide whether it is worth paying.
  2. Proposes a concrete fix, before and after, in the idiom of the file.
  3. Links the source. React docs, MDN, the TypeScript handbook, MUI. Each finding is an argument with a citation, not a matter of taste.

The eight pillars

Coverage spans eight pillars. Seven of them work on a single file:

  1. Component API and props, the backbone. Naming, boolean and callback conventions, string unions over boolean flags, discriminated unions that forbid impossible states, controlled and uncontrolled state, slots and children composition, generics, refs, styling APIs, accessibility props, server-component boundaries, JSDoc.
  2. State and data flow. Derivable values held in useState, props copied into state that then drift, the same fact stored in two places, prop drilling through layers that only forward it.
  3. Effects and lifecycle. Effects that compute derived data, fetches and subscriptions and timers with no cleanup (races and leaks), dependency arrays that lie about what the effect reads.
  4. Component structure and hooks. God components doing fetching, logic, and presentation at once; a component defined inside another (a brand new type every render); conditional hooks.
  5. Rendering correctness. Array index as key on a list that reorders or edits, direct mutation of state or props, nested ternary soup in JSX, copy-pasted blocks that want one parameterized helper.
  6. Accessibility in markup. onClick on a <div> with no role, no tabIndex, and no keyboard handler; div soup where semantic elements belong; form controls with no associated label.
  7. TypeScript discipline. any and as any and @ts-ignore, lying as casts, non-null ! on a value that can be null.

The eighth pillar, cross-file duplication, only runs at folder and repo scope, because it compares files against each other: a reusable component reimplemented inline somewhere else, a hook copy-pasted instead of shared, a type declared in two places that will silently drift apart.

How it rates the funk

Not every smell is an emergency, and a tool that treats them all as one teaches you to ignore it. react-stinky sorts every finding into three grades.

The grade is the point. It is the difference between a report you act on top to bottom and a report you skim and close.

The part that makes it usable

This is the thesis, so it gets its own section. Anyone can write a checker that flags every onClick, every any, and every index key. What you get back is a wall of noise you learn to scroll past, which is worse than no tool at all, because now the real problems are buried in the false ones.

react-stinky carries a guard. Every smell in the catalog has a "Don't flag" line, and a handful of rules cut across all of them.

  • Native HTML attributes stay bare. It will not tell you to rename disabled to isDisabled or onChange on a real <input> to onValueChange.
  • Established library conventions are not smells. MUI's open, slots, and sx; Radix's asChild. If the file already speaks that dialect, react-stinky speaks it back instead of correcting it.
  • Config-object props are correct for data-driven, fixed-layout components like a data grid. Not every config array wants to be compound components.
  • An effect is the right tool for real external synchronization. It flags the effect that computes derived state, not the one talking to a websocket, localStorage, or a non-React widget.
  • Index keys are fine on a static list that never reorders, inserts, or deletes. The bug only appears when items move.
  • One finding per real problem, and the smallest fix that removes the smell. A consistent local convention beats the catalog default.

What it refuses to touch

react-stinky has neighbors, and it stays out of their yards. Two concerns are handed off on purpose:

If those skills are not installed, react-stinky notes the finding in a single line and moves on. It does not reimplement them, and it does not pad its report with a category another tool owns. A skill that tries to do everything does nothing in particular well, so this one draws a hard edge around React and TypeScript maintainability and stops there.

Scope it to the question

You point react-stinky at as much or as little as you want, and it matches the work to the ask.

ScopeTriggerWhat it reads
Fragmenta pasted function or snippetonly that surface, stating what it assumed about anything offscreen
Fileone or more named fileseach file fully, every component, hook, and prop interface
Foldera directorythe folder, plus the cross-file duplication pass
Repo sweep"smell-check the codebase"the components, hooks, and modules, prioritizing shared and exported code

There is an honesty rule baked in. At single-file or fragment scope, where it cannot see other files, it tells you cross-file duplication was not checked rather than implying the code is unique. The tool says what it did not look at, not just what it found.

What a report looks like

Run it and you get findings keyed by file, each with a grade, a location, the cost, a before-to-after fix, and a source.

text
React Stinky report, src/components/SeedRow.tsx
[Rancid] clickable-nonsemantic (a11y markup), line 297
Smell: a <Stack> (renders a div) has onClick but no role, tabIndex, or keyboard handler.
Cost: keyboard and screen-reader users cannot trigger it; it is invisible to assistive tech.
Fix: render a real control (component="button" or an IconButton), or add
role="button" tabIndex={0} and an onKeyDown for Enter and Space.
Source: MDN button role
[Funky] effect-for-derived (state and effects), line 40
Smell: a useEffect plus setState computes `fullName` from `first` and `last`.
Cost: an extra render and a state value that can drift from its inputs.
Fix: compute during render, const fullName = `${first} ${last}`. Delete the effect and the state.
Source: React, You Might Not Need an Effect
Summary: 1 rancid, 1 funky across 1 file.

When nothing survives the guard, it says so plainly: "Smells fresh. No maintainability smells found." A clean bill is a result, not a failure to find work.

Where the rules come from

The rules react-stinky enforces are not invented for the occasion. Most of Pillar 1, the component-API backbone, comes straight out of Can't Maintain, a game I built that trains your eye for durable React APIs. It puts two versions of the same component side by side, you pick the one that ages better, and it tells you why, with a link to the React, TypeScript, MDN, or MUI source that backs the call.

react-stinky is the other half of that idea. The game teaches a human to recognize one smell at a time. The skill takes the same catalog and runs it across a whole file or repo at once, so you are not spotting each one by hand. One trains the eye, the other does the sweep, and both cite the same sources so the call is never just taste.

Can't Maintain has since grown into Cant, a hub of side-by-side learning games that now reaches past React into TypeScript, Git, SEO, testing, and UX. If a smell report makes you want to get faster at catching these before they ship, the game is where you practice. There is more about Cant on the projects page.

Try it

react-stinky installs into any agent that speaks the skills.sh format (Claude Code, Cursor, Codex, Cline, Windsurf, OpenCode):

bash
npx skills@latest add saschb2b/skills --skill react-stinky

Then point it at a component, a folder, or the whole repo and ask it to sniff.

Sources


S
Written by
Sascha Becker
More articles