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.
Sascha Becker
Author11 min read

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:
- 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.
- Proposes a concrete fix, before and after, in the idiom of the file.
- 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:
- 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.
- 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. - 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.
- 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.
- Rendering correctness. Array index as
keyon 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. - Accessibility in markup.
onClickon a<div>with no role, notabIndex, and no keyboard handler; div soup where semantic elements belong; form controls with no associated label. - TypeScript discipline.
anyandas anyand@ts-ignore, lyingascasts, 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 three grades of stink
Rancid. A real bug or a break in correctness, accessibility, or the server
boundary. Fix now. A control no keyboard can reach, a mutated state array, value || 50 quietly eating value={0}, a function passed across the server boundary.
Funky. A genuine maintainability drag, not a bug. Should fix. A boolean explosion that wants one union, a god component, an effect computing data you could derive during render.
Whiff. Minor or stylistic. Optional. A bare loading prop, JSDoc that just
restates the name. Real, but not worth blocking a change over.
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
disabledtoisDisabledoronChangeon a real<input>toonValueChange. - Established library conventions are not smells. MUI's
open,slots, andsx; Radix'sasChild. 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:
- Memoization (
useMemo,useCallback,React.memo) goes to thereact-compilerskill. - Color literals go to
theme-colors.
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.
| Scope | Trigger | What it reads |
|---|---|---|
| Fragment | a pasted function or snippet | only that surface, stating what it assumed about anything offscreen |
| File | one or more named files | each file fully, every component, hook, and prop interface |
| Folder | a directory | the 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.
textReact Stinky report, src/components/SeedRow.tsx[Rancid] clickable-nonsemantic (a11y markup), line 297Smell: 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 addrole="button" tabIndex={0} and an onKeyDown for Enter and Space.Source: MDN button role[Funky] effect-for-derived (state and effects), line 40Smell: 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 EffectSummary: 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):
bashnpx 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.
There's a skill for this
The full catalog, the stink ratings, and every "Don't flag" guard live as an
agent skill. Install it with npx skills@latest add saschb2b/skills --skill react-stinky or read the full react-stinky skill.
Sources
- Can't Maintain
The side-by-side React API quiz the catalog is distilled from. Pick the version that ages better, learn why, with the source that backs the call.
- saschb2b/skills
The open skills repo. react-stinky lives under engineering, installable on its own or with the whole set.
- skills.sh
The installer and registry that lets one skill folder work across Claude Code, Cursor, Codex, Cline, Windsurf, and OpenCode.
- React, You Might Not Need an Effect
The canonical source behind the effects and state pillars: derive during render, keep effects for true external synchronization.
- MDN, the button role
The accessibility reference behind the markup pillar: a clickable element needs a role, a tab stop, and keyboard handling.
