Modern React 19 + TS 6 + Tailwind v4 + Radix/shadcn + TanStack DOs and DON'Ts for high-performance UIs, clean code, separation of concerns, and avoiding AI-generated frontend pitfalls. Use when writing or reviewing UI code.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Modern React 19 + TS 6 + Tailwind v4 + Radix/shadcn + TanStack DOs and DON'Ts for high-performance UIs, clean code, separation of concerns, and avoiding AI-generated frontend pitfalls. Use when writing or reviewing UI code.
TL;DR
This stack is React 19.2, TS 6, Tailwind v4, TanStack Query 5, TanStack Table 8, Radix primitives, react-router-dom 7, Vite 8. Treat React 18, Tailwind v3, and useEffect-fetching as legacy.
React Compiler is stable (1.0, October 2025). Stop hand-rolling useMemo / useCallback / React.memo for render perf; keep them only as escape hatches for referential-identity contracts and effect dependencies.
Server data lives in TanStack Query. URL holds operator/explorer state. useState is for transient local UI. Never copy server data into useState.
pnpm --dir ui build and pnpm --dir ui lint are the project gates. Strict TS is on (noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch). Treat warnings as errors.
Generated frontend assets (pkg/web/static/assets/*, generated pkg/web/static/index.html) are NOT source. Edit ui/, run the install flow.
This skill complements project-coding (conventions and contracts), project-reviewing (review priorities), and project-content-surfaces (UI copy audience). Read those before relying on this one alone.
1. Stack-aware foundations
Verify the stack before applying any pattern from training data. Most "best-practice" snippets on the public internet are React 18 + Tailwind v3 + useEffect-fetching. They are wrong here.
Rule: when an idiom from a search result conflicts with this table, the table wins.
React Compiler is installed in opt-in annotation mode in ui/vite.config.ts
through @vitejs/plugin-react's reactCompilerPreset() and
@rolldown/plugin-babel. Existing manual memoization may remain until a
focused test/profile proves it is safe to remove; new useMemo,
useCallback, or memo() usage needs an identity, effect-dependency, or
measured-expensive-work reason. New "use memo" annotations require a route
profile and passing pnpm --dir ui build:budget evidence (from SOW-0080).
Package-lint rule: before adding an ESLint plugin, verify its current npm peer
range against this project's ESLint major. Do not peer-override lint plugins by
default during release hardening; record the incompatibility instead (from
SOW-0040).
UI components — JSX, classes, accessibility wiring. Almost no logic.
Data hooks — TanStack Query hooks, mutations, derived selectors. One concern per hook.
Domain helpers — pure functions in lib/ (formatting, classification, parsing). Unit-testable without React.
The repo already follows this: lib/api.ts is the typed client, lib/api-types.ts holds the shared types, formatters live in lib/admin-format.ts / lib/feed-health.ts / etc., and components import them. Mirror this when adding features.
When to extract
Component file longer than ~250 lines, or with non-trivial branching → split into subcomponents in a sibling file or a folder.
Files that export React components must not also export shared constants,
helpers, or non-component functions; react-refresh/only-export-components
is enforced. Move shared values to a plain .ts module and import them into
component files (from SOW-0073).
A useState + a few derived values + a TanStack Query call repeated in two places → extract to a custom hook (use<Thing>) in lib/ or alongside the component.
Render branches that look like if (loading) … if (error) … if (empty) … repeated across pages → extract a <QueryStates> boundary or use Suspense + ErrorBoundary.
Any component reaching for >4 props that all flow to a single child → consider composition (<Foo>{children}</Foo> with Slot) instead of prop pass-through.
A visual or heavy component is not "shipped" until a real route imports or
mounts it. When deleting unreachable frontend feature code, remove its
frontend API helpers, types, and direct package dependencies too (from
SOW-0040).
Route-splitting and dependency changes must keep the frontend bundle budget
green. Run make ui-budget after changing route imports, visualization
dependencies, chart/map libraries, or shared query/client modules (from
SOW-0056).
File-size heuristic (project policy)
tools/archposture enforces architecture posture for backend. The frontend has no automatic gate, but the same spirit applies: a single TSX file over ~400 lines is a smell. Recent SOWs split feed modal and feeds table into many small files (feed-modal-hero.tsx, feed-modal-identity.tsx, feed-modal-status-sections.tsx, feeds-table-body.tsx, feeds-table-filters.tsx, feeds-table-model.ts). Mirror that split.
BAD vs GOOD
// BAD: god component, JSX mixed with fetching, mixed with formatting, mixed with logic
export default function FeedPage({ name }: { name: string }) {
const [feed, setFeed] = useState<any>(null);
const [error, setError] = useState<any>(null);
useEffect(() => {
fetch(`/api/v1/sets/${name}`).then(r => r.json()).then(setFeed).catch(setError);
}, [name]);
const fmt = (n: number) => {
if (n > 1_000_000) return (n/1_000_000).toFixed(1) + "M";
return String(n);
};
if (error) return <div className="text-red-500">error</div>;
if (!feed) return <div>loading</div>;
return <div className="rounded-lg shadow p-4 bg-white dark:bg-slate-900">
<h1 className="text-xl font-bold">{feed.name}</h1>
<p>{fmt(feed.ip_count)} IPs</p>
{/* 600 more lines */}
</div>;
}
// GOOD: typed query hook, formatter helper, design-token classes, small component
import { useFeedDetail } from "@/lib/feed-detail";
import { formatIPs } from "@/lib/utils";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { QueryStates } from "@/components/query-states";
export function FeedPage({ name }: { name: string }) {
const query = useFeedDetail(name);
return (
<QueryStates query={query}>
{(feed) => (
<Card>
<CardHeader><CardTitle>{feed.name}</CardTitle></CardHeader>
<CardContent>{formatIPs(feed.ipCount)} IPs</CardContent>
</Card>
)}
</QueryStates>
);
}
3. State management hierarchy
Pick the lowest applicable level:
URL state for anything that affects operator workflow, sharability, or back/forward — the project does this in lib/admin-url-state.ts and lib/explorer-state.ts. Use useSearchParams or the typed wrappers, not useState.
Server state (anything from the daemon) → TanStack Query. Never duplicate it into useState to "transform it" — use select instead.
Local component state → useState for transient UI (open/closed, hover, in-flight form input). If two siblings need it, lift it; if many distant siblings need it, prefer URL or a small context.
Cross-cutting client state (theme, toast queue) → existing providers (next-themes, sonner). Do not introduce Redux/Zustand without a SOW decision; this codebase deliberately has none.
BAD vs GOOD: derived state
// BAD: useEffect to "compute" derived state (extra render, stale closure risk)
const [filtered, setFiltered] = useState<Feed[]>([]);
useEffect(() => {
setFiltered(feeds.filter(f => f.category === category));
}, [feeds, category]);
// GOOD: compute in render (React Compiler memoizes when needed)
const filtered = feeds.filter(f => f.category === category);
If the computation is genuinely expensive (>1ms in a profile), use useMemo. If you used useEffect because you wanted to re-fetch when input changes, that is a query, not state — see Section 4.
Every query goes through a typed hook in lib/ (existing pattern in ui/src/lib/api.ts). Components import the hook, never fetch directly.
Query keys are arrays whose first element is a stable feature root, followed by all variables that affect the response. Type matters (["feed", 1] ≠ ["feed", "1"]).
Prefer queryOptions() factories so the same key/fn pair is reusable for prefetch, useQuery, setQueryData, and invalidateQueries.
Split query option factories by route/concern. A single central
query-options module that imports every API helper can be hoisted by Rollup
into the public shell when one shared layout imports one factory; keep narrow
modules such as catalog, feed-core, feed-detail sections, admin, entities,
methodology, and search separate (from SOW-0050).
Use select to slice/transform; do not useState + useEffect to mirror server data.
Mutations call queryClient.invalidateQueries({ queryKey: [...] }) on success, with the most specific key that still covers everything that could have changed. Optimistic updates use onMutate + rollback in onError.
staleTime is "how long data is considered fresh" (no refetch). gcTime (default 5 min) is "how long inactive cached data lives in memory". Tune staleTime per query family; default gcTime is usually fine.
Never trigger writes from a useEffect that watches a query result. Use useMutation triggered by the user action.
queryOptions factory
// ui/src/lib/feeds.ts
import { queryOptions, useQuery } from "@tanstack/react-query";
import { fetchFeed, type FeedDetail } from "@/lib/api";
export const feedKeys = {
all: ["feeds"] as const,
detail: (name: string) => [...feedKeys.all, "detail", name] as const,
};
export function feedDetailOptions(name: string) {
return queryOptions({
queryKey: feedKeys.detail(name),
queryFn: ({ signal }) => fetchFeed(name, signal),
staleTime: 30_000,
});
}
export function useFeedDetail(name: string) {
return useQuery(feedDetailOptions(name));
}
Why this shape:
feedKeys.detail(name) is reusable for invalidateQueries, setQueryData, prefetch.
Define column defs outside the component or memoize them with useMemo. A new array on every render destroys table state and trashes performance.
Type the row shape; let TanStack infer accessors (accessorKey: "name" instead of accessorFn: (r) => r.name as any).
For tables larger than ~1k rows, switch to server-side: manualPagination: true, manualSorting: true, manualFiltering: true, then move the state into URL params and refetch via TanStack Query.
For very large tables that must stay client-side (rare), use TanStack Virtual on top of TanStack Table — TanStack Table itself does not virtualize.
Co-locate the column definitions with their domain types. The repo already does this in feeds-table-model.ts.
This project uses library mode — BrowserRouter + <Routes> + <Route> JSX, no routes.ts typegen, no Framework-mode loaders/actions. Do not introduce framework-mode features.
DOs:
Lazy-load expensive routes with React.lazy(() => import("./pages/admin")) and wrap in <Suspense>. Bundle splitting is the lever that keeps the public site fast.
Keep search-param state typed via the lib/admin-url-state.ts / lib/explorer-state.ts helpers; do not parse URLSearchParams ad-hoc in components.
Use useParams<{ name: string }>() — narrow it once at the route boundary, then pass typed values down.
Use <ScrollRestoration /> if you ever change scroll behavior; verify with the home/admin layout.
DON'Ts:
Do not migrate to framework mode silently. It changes the build, the index.html shape, and how SSR/typegen works. That is a SOW-level decision.
Do not add a second router (createBrowserRouter data-router) alongside the JSX router. Pick one.
Do not derive routing decisions from window.location directly inside components — use the router hooks; otherwise SSR/test mounts misbehave.
7. Styling with Tailwind v4 + Radix + shadcn-style
Tailwind v4 reality in this repo
Tailwind v4 changes are real and AI tools regularly produce v3 syntax. The hard rules:
@import "tailwindcss" (single line) replaces the v3 trio of @tailwind base; @tailwind components; @tailwind utilities;.
This project mounts a legacy tailwind.config.ts via @config "../tailwind.config.ts" for design tokens. That is a deliberate hybrid — do not delete the config file; do not move all theme tokens into CSS without a SOW.
Custom utilities use @utility (v4), not @layer utilities (v3).
Theme values are CSS variables and the theme() function still works inside @apply if needed; prefer var(--color-foo) in new CSS.
Renamed utilities: shadow-sm→shadow-xs, rounded-sm→rounded-xs, outline-none→outline-hidden, flex-shrink-0→shrink-0, flex-grow-0→grow-0. Do not use bg-opacity-* / text-opacity-* / border-opacity-* — use the /N opacity modifier (bg-black/50).
Important modifier: trailing, not leading: flex! not !flex.
Default border-color is currentColor in v4; the design tokens in index.css set explicit border colors where needed. If a border looks wrong, the fix is a token, not border-gray-200.
If a generated snippet has any of tailwind.config.js (top-level reconfig), @tailwind base/components/utilities, @layer utilities { … }, or bg-opacity-* — that snippet is v3 and must be rewritten before commit.
shadcn-style composition
This repo follows shadcn conventions exactly:
Primitives copied into ui/src/components/ui/ (button.tsx, dialog.tsx, select.tsx, …). They are not a npm package — they are owned source code. Edit them in place when behavior changes.
cn() lives at @/lib/utils and is the single class-merging helper. Use it everywhere; it merges via clsx + tailwind-merge so conflicts (p-4 vs p-2) resolve last-wins.
Variant systems use class-variance-authority (cva) — see button.tsx for the canonical shape (variants, defaultVariants, exported VariantProps).
asChild + Slot is the way to forward a primitive's behavior onto a different element (e.g., <Button asChild><Link to="/x">Go</Link></Button>). The child must be a single focusable element that spreads incoming props and forwards refs. In React 19, "forward refs" means accept ref as a normal prop — no forwardRef.
// GOOD: use the existing Button (cva, design tokens, a11y from Radix Slot)
import { Button } from "@/components/ui/button";
<Button variant="default" size="sm">…</Button>
BAD vs GOOD: theme tokens
// BAD: hardcoded hex, will not switch with theme
<div className="bg-[#0f131b] text-[#fafaf7]">…</div>
// or
<div style={{ background: "#0f131b" }}>…</div>
// GOOD: design tokens that adapt to light/dark
<div className="bg-background text-foreground">…</div>
Dark mode
The project uses next-themes with a .dark class on <html>. Do not introduce a data-theme selector or roll a separate theme system. Any new color must exist in ui/src/index.css for both :root and .dark (or live in the Tailwind config).
Layout/scale primitives invoked from React; never let D3 own the DOM
Direct DOM mutation (d3.select(svgRef).…) — keep React in charge
react-simple-maps + topojson-client
2D world maps with country choropleth
3D, panning at high zoom
Three.js / globe lifecycle if reintroduced
The current frontend does not carry direct Three.js/globe dependencies. If a future SOW reintroduces a Three.js scene (directly or through react-globe.gl), enforce these before closing that SOW:
useEffect(() => {
// mount scene/objects here
return () => {
// dispose every Geometry, Material (or array), Texture, RenderTarget
scene.traverse((obj) => {
if (obj instanceof Mesh) {
obj.geometry.dispose();
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => {
for (const k of Object.keys(m)) {
const v = (m as any)[k];
if (v && typeof v.dispose === "function" && v.isTexture) v.dispose();
}
m.dispose();
});
}
});
renderer?.dispose();
renderer?.forceContextLoss();
};
}, [/* keys that recreate the scene */]);
Common symptoms of missed cleanup: GPU memory creeps up across navigation, hot-reload eventually crashes the tab, renderer.info.memory rises monotonically.
Other rules:
Lazy-load any 3D/globe feature: const Globe = lazy(() => import("./globe")). Three.js + globe.gl is hundreds of KB; do not ship it in the main chunk for users who never see the feature.
Do not put scene state in React state — use refs. Re-rendering must not recreate the WebGL context.
Cancel requestAnimationFrame in cleanup; otherwise the loop keeps a closure to old props.
SVG vs Canvas
Recharts/D3-as-React produce SVG. SVG is fine up to a few thousand DOM nodes; past that, switch to Canvas (e.g., a custom Canvas chart, or D3 + canvas). Force-directed graphs above ~500 nodes should be Canvas/WebGL.
Public site requirements (from .agents/sow/specs/operating-principles.md): cache-first, fast TTFB, no expensive request-time computation. The frontend's job is to load only what the visible surface needs.
Bundle splitting
Route-level code splitting by default: every top-level page (pages/admin.tsx, pages/feed.tsx, pages/methodology.tsx, etc.) is lazy()-imported and wrapped in <Suspense> at the router level.
Heavy libs (large D3 modules, large Recharts compositions, and any future Three.js/globe libraries) are dynamic-imported inside their feature, not statically imported in the route module.
Lazy route pages are not enough when their layouts fetch data. Lazy-load
route-specific layouts too, and inspect the emitted chunk list or endpoint
strings after data-boundary refactors. SOW-0050 found an eager AdminLayout
import leaking admin query helpers into the main entry chunk.
Vite manual chunks: only intervene when the bundle visualizer shows a hot path. Do not configure manualChunks from training-data templates blindly — broken chunking can recreate full vendor blobs on every route.
Avoid barrel files (index.ts re-exporting many submodules). They defeat tree-shaking. Import directly from the file.
Render performance
With React Compiler enabled the right reflex is "let the compiler do it":
Do not wrap every callback in useCallback and every value in useMemo. The compiler memoizes render-time work granularly, including conditionally after early returns.
Keep useMemo / useCallback only when:
The value/function crosses a referential-identity contract (third-party DnD, charts, map libs, debounce/throttle factories).
The value is a useEffect dependency and should not over-fire.
You measured a real perf cost (>1ms in profile) that the compiler did not eliminate.
Use useTransition to mark non-urgent UI updates (filtering, large list re-render) so input stays responsive.
Use useDeferredValue for derived list inputs (search filter against a long list) to skip stale renders.
Lists: provide stable keys; never use array index as key when the list reorders or items are added/removed.
Reserve space for async content with <Skeleton> (already in components/ui/skeleton.tsx). No layout shift (CLS) when data arrives.
Use <Suspense fallback={…}> boundaries at meaningful points (route, panel, expensive widget) — not the whole tree.
Use <ErrorBoundary> around any third-party visualization. A chart or future Three.js crash should not kill the page.
TTFB / public site
Public pages must render with cached/published artifacts only. Do not introduce a UI flow that calls a route which would build artifacts on demand. Cross-check with project-coding and .agents/sow/specs/operating-principles.md before adding a new public surface.
Defer non-critical JS: fonts via <link rel="preload"> only when measured; otherwise let the browser's default policy run.
Strict is on. Treat the compiler as a teammate, not an obstacle.
Hard rules
No any. Replace with the precise type, unknown + narrowing, or a discriminated union. If a third-party type is any, wrap it in a typed adapter at the boundary.
No as Foo to silence a real type error. Casting hides bugs the compiler already caught. The legitimate uses are: narrowing after a runtime check (as const, as typeof X[number]), branded type construction inside a factory, and discriminated-union selection where the compiler cannot infer.
No non-null ! assertions on values that could be undefined at runtime. Narrow with if (!x) return null; or use a type guard.
No @ts-ignore / @ts-expect-error without a comment explaining why and what would unblock removing it.
Discriminated unions over flag bags: type Status = { kind: "ok"; data: T } | { kind: "err"; error: Error } beats { ok: boolean; data?: T; error?: Error }.
Exhaustiveness in switches: end the switch with default: const _exhaustive: never = value; throw new Error(...). With noFallthroughCasesInSwitch already on, a missing case becomes a compile-time error.
satisfies
satisfies lets a value conform to a wider type without losing its narrow inferred type. Useful for keyed configs and column defs.
Use a phantom-typed nominal wrapper for values that look like primitives but carry invariants (a validated CIDR, a feed name that exists, a sanitized HTML string).
type CIDR = string & { readonly __brand: "CIDR" };
function parseCIDR(s: string): CIDR | null { /* validate */ }
function summarize(c: CIDR) { /* … */ }
summarize("10.0.0.0/8"); // compile error — must go through parseCIDR first
unknown over any at boundaries
Untyped data (third-party JSON, localStorage, postMessage) is unknown. Validate then narrow:
// BAD
const cfg: any = JSON.parse(raw);
return cfg.foo.bar;
// GOOD
const cfg: unknown = JSON.parse(raw);
if (typeof cfg === "object" && cfg && "foo" in cfg) {
const foo = (cfg as { foo: unknown }).foo;
// narrow further or use a schema validator
}
For repeated parsing, prefer a schema validator (Zod/Valibot — neither is in package.json today, so introducing one is a SOW-level decision).
Don't render only an icon button without an accessible name (aria-label or <span className="sr-only">).
BAD vs GOOD
// BAD: clickable div, no role/keyboard, no label
<div onClick={open} className="rounded-md p-2 hover:bg-accent">
<PencilIcon />
</div>
// GOOD: real button, label, focus ring inherited from Button
<Button variant="ghost" size="icon" aria-label="Edit feed" onClick={open}>
<PencilIcon />
</Button>
12. Security
This UI talks to the daemon's typed API, but it also renders content from third-party feed pages, methodology markdown, and admin operator inputs. Treat everything that touches HTML as hostile until proven otherwise.
Use dompurify (already a dep) for any HTML you must inject as raw markup. The repo has a helper at lib/safe-html.ts — use it. Never inline DOMPurify.sanitize calls scattered across components without going through the helper.
Prefer plain text + JSX over HTML strings. If a description has bold/links and is short, render Markdown server-side or use a tightly-scoped renderer; do not free-form HTML.
URLs from server data: validate scheme before rendering as <a href>. Reject javascript:, data:, and unknown schemes. Use new URL(value, base) and check url.protocol.
Open external links with rel="noopener noreferrer" and consider target="_blank" only when justified.
No inline event handlers built from data (e.g. onclick="…" strings inside HTML you inject). React JSX does not execute string handlers, but unsanitized raw HTML can still smuggle them — always go through lib/safe-html.ts.
No JSON-in-<script> for runtime config without escaping; use a typed bootstrap endpoint.
Do not log tokens, API keys, or admin auth in console.*. Browser dev tools = log scraping by anyone with the page open.
CSP: the daemon ships a CSP. New external scripts/styles/fonts/images are a CSP change — it's a SOW decision, not a one-line PR.
13. Working with AI-generated UI code
This is where most reviews fail. LLMs are trained predominantly on React 18 + Tailwind v3 + useEffect-fetching tutorials. Anything they emit that matches that profile is wrong here. Read this section every time you accept a generated component.
Concrete failure modes (community evidence)
forwardRef everywhere. Generated components wrap with React.forwardRef((props, ref) => …). In React 19, accept ref as a regular prop. Strip the wrapper.
tailwind.config.js reconfigurations / @tailwind base; @tailwind components; @tailwind utilities; — v3 syntax. Replace with @import "tailwindcss" at most; this repo additionally mounts a legacy config via @config. Do not paste a fresh tailwind.config.js — touch ui/tailwind.config.ts only with a SOW. (Tailwind v4 / Claude 3.7 Sonnet write-up; official discussion)
useEffect for fetching. "Fat component" anti-pattern. Replace with useQuery through a typed hook in lib/. Race conditions, no cancellation, no cache reuse. (Vercel React best practices)
useState mirroring server data ("for transformations"). Use select in useQuery.
Missing dependency arrays / stale closures. Either fix the deps or rewrite to not need an effect.
any and as any to silence TS errors. Replace with the precise type. The lint will fail anyway.
! non-null assertions on optional values. Narrow with a guard.
Cargo-culted useMemo / useCallback on every primitive value/lambda. Remove. The Compiler memoizes render work; manual memo here just bloats the file.
"Hallucinated" Radix or shadcn API surface. Examples: <Dialog.Trigger asChild> written as <DialogTrigger asChild={true}> from a different lib version, cva(...).default("primary") (no such API), cn(... ?? "") confusion. Cross-check against the actual installed primitives in .
Reviewer checklist for AI-generated UI
Before accepting any AI-generated component or skill output:
No forwardRef. Refs are props.
No useEffect for fetching, no manual loading state. Goes through lib/api.ts + TanStack Query.
No useState mirroring server data. select in queries instead.
No useEffect for derived state. Computed in render.
No any, as any, @ts-ignore, ! assertion. Strict-TS-clean.
No useMemo / useCallback / React.memo without a clear identity-contract or measured-perf justification.
No tailwind.config.js add, no @tailwind base/components/utilities, no @layer utilities for new utilities. v4 syntax.
No bg-opacity-* / flex-shrink-* / flex-grow-* / pre-rename utilities.
No hex colors / inline styles for theme values. Design tokens only.
No clickable <div>. Real semantic elements.
Every icon-only button has aria-label.
cn() from @/lib/utils for class merging. No string concatenation of conditionals.
Variants via cva for any reusable component family.
Lazy-load any heavy widget (charts, map, and any future globe). No global static imports of heavy visualization libraries.
If Three.js / globe code is reintroduced, it has a working useEffect cleanup that disposes geometries, materials, textures, renderer.
If a generated patch fails any of these, send it back. Do not "fix while landing" — the same model will reproduce the pattern next time unless the prompt/skills are updated.
14. Quick reference — DO / DON'T
Concern
DO
DON'T
React refs
Accept ref as prop
forwardRef, React.forwardRef
Memoization
Trust the React Compiler; manual memo as escape hatch
Wrap every callback/value in useMemo / useCallback
Server data
TanStack Query hook in lib/
useEffect + fetch + useState in component
Derived state
Compute in render
useEffect to copy/transform
Form actions
useActionState / <form action={...}> for new forms
Verify before relying on anything time-sensitive: React Compiler ESLint rule names (set-state-in-render, set-state-in-effect, refs) and React Router v7 minor-version lazy-loading API; both are still evolving as of the cited dates.
<table> with hand-rolled sort/filter/paging logic
Router
react-router-dom 7 in library mode (no routes.ts typegen)