Anti-patterns
The previous pages say what to do. This one says what you will find — in an existing app, or in your own Friday-night PR.
Every entry has the same shape: how to spot it, why it hurts, and the refactor.
1. The God component
Spot it: a .tsx with 400+ lines, useState mixed with fetch, formatting,
validation and JSX.
It hurts because: five reasons to change in one file — every edit has a chance of breaking something unrelated, and nobody reads the whole file before editing.
Refactor: three cuts, in this order.
Always in that order: removing the network first is what makes the rest testable.
2. fetch inside the component
Spot it:
useEffect(() => {
fetch(`/api/orders?page=${page}`)
.then((r) => r.json())
.then(setOrders);
}, [page]);
It hurts because: untestable without a server, no cache, no dedupe, no error handling, no cancellation — and the same endpoint called on two screens fires two requests.
Refactor: service + useQuery. It becomes one line in the component:
const { data = [], isLoading } = useOrders(page);
See Data flow.
3. The syncing useEffect
Spot it: a useEffect whose body only calls setState from another piece of state
or a prop.
useEffect(() => {
setFullName(`${first} ${last}`);
}, [first, last]);
It hurts because: an extra render, a window where the value is stale, and a second source of truth.
Refactor: compute it.
const fullName = `${first} ${last}`;
4. Server state duplicated in a store
Spot it: a useOrdersStore holding an array of orders that came from the API, plus
a fetchOrders() that fills the array.
It hurts because: you re-implemented a cache — but without invalidation, without
revalidation, without staleTime. The symptom is always "the screen doesn't update
after the POST".
Refactor: server data → Query. The store keeps only what is client state (session, theme, cart).
5. Prop drilling 4 levels deep
Spot it: the same prop crossing components that don't use it, just to reach a grandchild's grandchild.
It hurts because: every component on the path knows a piece of data that isn't theirs, and adding one field touches five files.
Refactor: three options, in order:
- Composition — pass
childrenalready assembled, instead of data for the child to assemble. - Context — when it's genuinely global (theme, session, i18n).
- Store — when it's global and changes often.
Context is not the first answer
Context re-renders every consumer when the value changes. Composition solves most drilling cases at zero render cost.
6. Pass-through
Spot it: a function, hook or component whose body only forwards its arguments.
// ❌ a layer with no logic at all
export async function listCustomers(params: GetListParams) {
return dataProvider.getList<Customer>("customers", params);
}
It hurts because: it's one more indirection to read, one more file to open, and zero behaviour added. And when the signature below changes, you edit two places.
Refactor: delete it and call directly — useList<Customer>("customers", params).
Legitimate exceptions: an intentional abstraction boundary (a public facade over a private implementation, a framework hook, an interface implementation). When that's the case, the JSDoc says so.
7. Ceremony
Spot it: a layer created out of symmetry, not need — types/, constants/,
mappers/ with one two-line file each, a mapper that copies field by field without
transforming anything.
// ❌ maps A to A
export function toOrder(dto: OrderDto): Order {
return { id: dto.id, code: dto.code, status: dto.status };
}
It hurts because: every empty layer is one more place to search and one more to keep in sync, in exchange for nothing.
Refactor: delete it. Structure gets added when the second case shows up, not before.
8. A giant barrel in the app
Spot it: a src/index.ts (or components/index.ts) re-exporting everything, and
imports like import { X } from "@/components".
It hurts because: import cycles (A → barrel → B → barrel → A), and Vite reprocesses the whole tree on every edit — the dev server gets slow for no visible reason.
Refactor: a barrel only at the feature boundary (features/orders/index.ts).
Inside the feature and inside components/, use direct paths.
In the SDK it's different — on purpose
A published package needs the barrel: it's the consumer's import contract. The cost
is paid with preserveModules at build time, which preserves the module graph so the
app's bundler can tree-shake. An app publishes nothing, so it has no such problem to
solve.
9. any and as to silence the compiler
Spot it: as any, @ts-ignore, props: any, ! scattered around.
It hurts because: the error doesn't disappear — it moves, to runtime, far from the cause.
Refactor: unknown + validation at the edge, discriminated unions, early returns.
See Strong typing.
10. Booleans as variants
Spot it: <Alert error warning info small />.
It hurts because: it represents impossible states (error warning together) and the
precedence hides in the CSS.
Refactor: a string union — <Alert variant="error" size="sm" />.
11. Tests coupled to implementation
Spot it: container.querySelector(".tempest_card_1a2b"), a 400-line snapshot, an
assertion that useState was called.
It hurts because: it fails when the CSS changes and passes when the behaviour breaks. False confidence is worse than no test.
Refactor: query by role (getByRole) and check observable behaviour. See
Testing strategy.
12. The silent catch
Spot it:
try {
await payOrder(id);
} catch {
// ignore
}
It hurts because: it trades a visible error for invisible wrong behaviour. The user clicks, nothing happens, and there is no log.
Refactor: handle it (specific message), or don't catch — let it rise to the ErrorBoundary and record it in the logger.
13. Hardcoded magic strings
Spot it: if (status === "paid") in seven files; ["orders", "list", page] built by
hand in the query and in the invalidation.
It hurts because: the typo isn't a compile error. "payed" in one of the seven files
is an if that never fires.
Refactor: a union plus a derived constant. Query keys via
createQueryKeys; labels via a satisfies Record<Status, string> table
(Typing).
14. Inline styles instead of tokens
Spot it: style={{ color: "#2563eb", padding: 12 }}.
It hurts because: it ignores dark theme, ignores density, has no states
(:hover/:focus-visible) and turns into 40 different shades of blue across the app.
Refactor: CSS Module + --tempest-* tokens:
.card {
color: var(--tempest-text);
background: var(--tempest-surface);
padding: var(--tempest-space-3);
}
tempest doctor finds part of this
The CLI analyses the project's CSS: non-existent properties and tokens, duplicate declarations, syntax the browser drops, repeated blocks that want a utility class.
npx tempest doctor
npx tempest fix --dry-run
15. Re-implementing what the SDK already has
Spot it: your own Modal, your own useDebounce, your own formatCurrency, your own
document-number mask.
It hurts because: focus trap, Esc, focus restoration, aria-modal, scroll lock and
portal are six details the homemade version doesn't have — and each one is an
accessibility bug.
Refactor: search first. There are 117 components, 46 hooks, Brazilian utilities (CPF/CNPJ/postal code/phone/currency) and 384 root exports. See the components catalogue, hooks, and utilities.
The ranking, if you can only attack three
| Priority | Anti-pattern | Why |
|---|---|---|
| 🔴 1 | fetch in the component |
blocks testing, caching and reuse at once |
| 🔴 2 | Server state duplicated | direct cause of "screen shows stale data" |
| 🔴 3 | any / as to silence an error |
moves the bug into production |
| 🟡 4 | God component | painful, but incremental improvement works |
| 🟡 5 | Silent catch |
cheap to fix, high payoff |
| 🟢 6 | Ceremony / pass-through | annoying, not dangerous |
Recap
- The three expensive ones:
fetchin the component, duplicated server state,anyto silence the compiler. - The God component is fixed in order: network → logic → JSX.
- A
useEffectthat only callssetStateis derived state in disguise. - Pass-through and ceremony are cost without return — delete them.
- Before writing a primitive, search the SDK.
Next: Review checklist — the one-page version.