building-frontends
This is the backbone skill for frontend work. Read it before writing any UI code — it sets the defaults and non-negotiables so every screen comes out consistent, accessible, and maintainable, then hands off to the specific skills for the detailed procedures. If a rule here conflicts with a local repo convention, the repo wins; otherwise, follow it.
First, understand before you build
- Read the existing UI before adding to it. Match the component library, styling approach, state tooling, routing, and data-fetching patterns already in use. A new screen that invents its own button, its own modal, its own fetch pattern is technical debt on arrival.
- Build from the design system, don't reinvent it. Reach for the existing primitives (Button, Input, Modal) before writing a new one. If a primitive is missing, add it to the system, not inline in one screen.
- Think in states, not screens. Every view has more than a happy path: loading, empty, error, partial, and the permission-denied variant. Design all of them up front — a UI that only handles "has data" is half-built.
Component architecture — the rules
- Separate container from presentation. Presentational components take props and emit callbacks and hold no business logic or data fetching; containers wire data and behavior to them. This keeps the reusable pieces dumb and testable.
- Compose small components; lift state only as far as needed. State lives at the lowest common ancestor of the components that use it — no higher. A giant top-level component holding everyone's state is a re-render bomb and a merge-conflict magnet.
- Props are a contract: type them honestly. Extend the underlying element's props so you inherit its behavior; use discriminated unions for mutually-exclusive shapes; no
any. A component's prop types are its documentation. - Derive, don't duplicate. If a value can be computed from props/state during render, compute it — don't store a copy and sync it with an effect. The
useEffect-to-mirror-a-prop pattern is a bug source (flicker, stale values); it is almost never correct. - Effects are for synchronizing with the outside world (subscriptions, imperative DOM, non-React widgets), not for reacting to your own state. If an effect only reads and writes React state, the logic belongs in an event handler or a derived value.
→ For building a single reusable component to standard (controlled/uncontrolled, a11y, all states, forwarded refs), use react-component-builder.
State — put each piece where it belongs
The most common frontend mess is state in the wrong place. Classify every piece and place it once:
- Server data → a data-fetching cache (React Query/SWR/RTK Query). It's a cache of someone else's data, not your state. Never copy it into
useState— you'd own cache invalidation and get it wrong. - Shareable/refresh-surviving UI (filters, tab, sort, selected id) → the URL. If it should survive a refresh or be linkable or work with the back button, it belongs in query params, not component state.
- Form input → a form library / local state until submit.
- Local UI (modal open, hover, wizard step) → local
useState, lifted only as needed. - Genuinely app-wide client state (auth, theme, flags) → a small global store, selected narrowly. Not a dumping ground, and never server data.
→ For the full decision tree, cache rules, and normalization, use frontend-state-architecture.
Data fetching — the rules
- Use the cache library; don't hand-roll fetch-in-
useEffect. Manual fetching leaks race conditions (out-of-order responses), lacks dedup/caching, and reinvents the library badly. If you truly must, cancel stale requests with anAbortControllerand ignore late responses. - Every async boundary renders loading, error, and empty — not just success. A spinner that never resolves on error is a broken screen.
- Mutations invalidate the cache (or optimistically update with a rollback path). An optimistic update with no rollback on error is a bug.
- Handle the network reality: retries, stale-while-revalidate where appropriate, and a visible, recoverable error state (a "retry" affordance, not a dead end).
Forms — the rules
- Controlled where you need validation/derived UI; uncontrolled for simple cases. Pick one per form and be consistent.
- Validate on the right trigger: on blur / on submit, not aggressively on every keystroke (which fights the user as they type). Show errors after the field is touched, not before.
- Field-level, specific error messages tied to inputs via
aria-describedby; mark invalid fields witharia-invalid. "Something went wrong" is not a validation message. - Disable submit while submitting; make submission idempotent. Prevent the double-submit; show progress; on failure, keep the user's input and show what failed.
- Never trust client validation for security — it's a UX affordance; the server validates authoritatively.
Accessibility is a requirement, not a feature
- Semantic HTML first, ARIA second. A real
<button>/<a>/<label>/<nav>before a<div>with handlers. ARIA only fills the gaps semantic elements can't. - Keyboard-operable everything. Every interactive element reachable and usable by keyboard, with a visible focus ring. Never
outline: nonewithout a replacement. - Manage focus for overlays: move focus in on open, trap it while open, restore to the trigger on close.
- Non-visual state:
aria-expanded,aria-selected,aria-invalid,aria-busywhere relevant; accessible names on every control. - Don't rely on color alone; meet WCAG AA contrast; respect
prefers-reduced-motion. - Test by querying the accessible tree (by role + name), which both verifies behavior and enforces a11y.
Performance — measure, don't guess
- Ship less JavaScript. Code-split by route; lazy-load heavy, below-the-fold, or interaction-triggered components (modals, editors, charts). The fastest script is the one you don't send.
- Don't optimize on a hunch. No blanket
useMemo/useCallback/memo— each has a cost and most components are cheap. Add memoization only where a profiler shows an expensive render firing too often. Virtualize genuinely long lists. - Guard the vitals: reserve space for images/async content (no layout shift), prioritize the LCP image, keep the main thread free for interaction. Set a bundle budget in CI so wins don't regress.
→ For diagnosing and fixing Core Web Vitals against measurements, use web-performance-audit.
Styling
- Follow the repo's system (Tailwind, CSS Modules, CSS-in-JS) — don't add a second styling paradigm.
- Use design tokens (spacing, color, type scale) rather than magic numbers; theme via tokens so light/dark and rebrands are a config change, not a find-replace.
- Responsive and fluid by default; relative units, flexbox/grid; wide content (tables, code) scrolls in its own container so the page never scrolls horizontally.
Testing stance
- Query the way a user (and assistive tech) does — by role, label, and text — via Testing Library. Avoid test ids and class selectors unless nothing else is stable.
- Test behavior and the non-happy states, not implementation details (no asserting on internal state or specific hook calls) — so tests survive refactors.
- Include an a11y assertion (axe) where tooling allows; add a keyboard-interaction test for custom widgets.
- Every bug fix gets a regression test.
Procedure for any frontend task
- Read the surrounding UI; reuse the design system and existing patterns.
- Enumerate every state (loading/empty/error/partial/denied/success) before building.
- Classify state and place each piece (server-cache / URL / form / local / global).
- Fetch via the cache library with loading+error+empty; mutations invalidate or roll back.
- Build components container/presentation split, typed honestly, deriving not duplicating.
- Run the a11y checklist (semantic HTML, keyboard, focus, non-visual state, contrast).
- Keep it fast by construction (code-split, no speculative memo); guard vitals + bundle budget.
- Test by role/behavior, cover the non-happy states, add regression tests; confirm against the specific skill's
Definition of done.
Definition of done
- Reuses the design system and repo conventions; no duplicated primitives or styling paradigms.
- All states implemented (loading/empty/error/partial/denied), not just the happy path.
- State placed correctly: server data in the cache, shareable UI in the URL, no
useStatemirrors of server data. - Data fetching via the cache lib with loading/error/empty and rollback on optimistic writes.
- Accessible: semantic HTML, keyboard-operable, focus managed, non-visual state, AA contrast.
- Fast by construction: code-split, no speculative memoization, vitals guarded, bundle within budget.
- Tests query by role/behavior, cover non-happy states, include a11y + regression coverage.