TypeScript, React, and web front-end guidance that applies to every project. See
README.md for how these files are organised and maintained.
Define a type for it where one is possible.
handleClick for onClick, handleKeyDown for onKeyDown.
Prefer not to expose a function as public unless something outside the class actually needs it.
One general rule instead of a .trim() per field: pass the whole form-data object through a
recursive trim-all-strings helper (utils/deepTrimStrings.ts) just before building the request.
Per-field trimming invites the asymmetry where only some fields are trimmed. Fields that need
empty→undefined mapping on top (nullable wire fields) keep their explicit handling.
Controls should grow and shrink to fit the available space and their content.
Restoring a scroll or pan/zoom position across a data change needs an identical layout, or an anchor
Reapplying the same offset only shows the same content if the DOM lays out identically. When the data varies — different-sized rows, an optional panel, a wider selected item — the same transform lands somewhere else. Make the varying pieces a uniform size, or pin to a specific element's on-screen position, instead of trusting the raw offset.
An interactive element needs tabindex="0", an aria-label, and a keyboard handler alongside its
click handler — not just the mouse path.
A tuple's positions are its contract, and reordering one while the types stay the same is invisible to both the compiler and the reader — every existing destructure silently starts meaning something else. Append the new element even when another order reads better in isolation.
// useUrlState returns [value, setValue]. The debounced variant appends, so an existing
// `const [searchTerm, setSearchTerm] = useUrlState(...)` keeps meaning what it meant.
const [searchTerm, setSearchTerm, debouncedSearchTerm] = useDebouncedUrlState(
"search",
"",
SearchDebounceMs,
);
// Leading with the debounced value would compile everywhere and silently make every
// input bound to position 0 lag by the debounce interval.toHaveBeenCalledWith compares the whole argument list, so an assertion that spells out every
parameter fails the moment one is added — in a file that otherwise has nothing to do with the change.
Pin the arguments under test and let a matcher absorb the others.
// Adding an AbortSignal parameter broke this assertion in a test about role filtering
expect(service.GetUsers).toHaveBeenCalledWith(
expect.objectContaining({ RoleFilter: UserRole.Pending }),
);
// After
expect(service.GetUsers).toHaveBeenCalledWith(
expect.objectContaining({ RoleFilter: UserRole.Pending }),
expect.anything(),
);Vitest transpiles rather than type-checks, and ESLint's type-aware rules cover far less than the
compiler. A change can therefore break the build while the whole suite and the linter stay green.
Run tsc --noEmit (or the production build) before believing a refactor landed.
A synthetic element.click() skips the pointerdown/move/up sequence, so it sails past the machinery a
real click goes through — pointer capture, hit-testing, drag-vs-click discrimination — and can pass
while the real interaction is broken. Drive the check through real events: a Playwright locator
click, or dispatched PointerEvents.