# Frontend React Design Guide Durable principles for writing React applications in this repository. These rules apply to the `dashboard/` app and any future React surfaces. ## Tech baseline - React 19, Vite, TypeScript - No state management library unless proven necessary - Profiler-driven optimization, not guesswork --- ## 1. Understand what actually triggers re-renders React has exactly three re-render triggers: 1. **A component's own state changes** (`useState`, `useReducer`) 2. **A parent component re-renders** (children re-render unconditionally) 3. **A consumed context value changes** (`useContext`) **Props are not a trigger.** Props are inputs to a render that is already happening. When a parent re-renders, React calls every child component function regardless of whether props changed. This is normal, expected, and fast. ```tsx // Child re-renders every time Parent re-renders, // even though `count` is always 5. const Child = ({ count }: { count: number }) => { console.log("Child rendered"); return
Count: {count}
; }; const Parent = () => { const [parentCount, setParentCount] = useState(0); const childCount = 5; // never changes return ( <> ); }; ``` If you expected Child to skip rendering because its props didn't change, your mental model is wrong. Fix the model before reaching for `React.memo`. ### Render vs. commit A **render** means React called your component function and diffed the output. A **commit** is when React actually touches the DOM. Many renders result in zero DOM changes. Renders are cheap; unnecessary commits are what hurt. --- ## 2. Keep state close to where it's used The single most effective performance technique in React is **not lifting state higher than it needs to be**. Every state change re-renders the component that owns it and all descendants. Keep the blast radius small. ```tsx // Bad: search state lives too high, every child re-renders on keystroke const Dashboard = () => { const [search, setSearch] = useState(""); return ( <> ); }; // Good: search state is local, siblings are unaffected const Dashboard = () => { const [data, setData] = useState([]); return ( <> ); }; const SearchSection = () => { const [search, setSearch] = useState(""); return ; }; ``` When `search` changes, only `SearchSection` re-renders. `DataTable` and `Charts` don't even know it happened. No memo required. This is **composition** — React's primary performance tool since day one. --- ## 3. Separate render from behavior When a component has non-trivial logic (data fetching, state machines, complex event handling), split it into two files: a hook that owns the behavior and a component that owns the JSX. ``` FeatureX/ FeatureX.tsx (render logic only) useFeatureX.ts (hook: data fetching, state, side effects) helpers.ts (pure functions used by the hook) components/ (sub-components local to FeatureX) ``` The hook returns a plain object that the component destructures: ```tsx // useStatsPanel.ts export function useStatsPanel() { const [stats, setStats] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { fetchStats().then(setStats).finally(() => setIsLoading(false)); }, []); return { stats, isLoading, refresh: () => fetchStats().then(setStats) }; } // StatsPanel.tsx export function StatsPanel() { const { stats, isLoading, refresh } = useStatsPanel(); if (isLoading) return ; return
...
; } ``` This separation has direct performance benefits: - **State stays colocated.** The hook lives next to the component that consumes its state, so re-renders don't propagate up. - **Composition becomes natural.** When a component gets big, you extract a sub-component with its own hook. Each sub-tree re-renders independently. - **Testing is simpler.** You can test the hook's logic without rendering anything, and test the component's output with stubbed hook data. When to skip the split: if the hook would be 3-4 lines (a single `useState`, a toggle), keep it inline in the component file. --- ## 4. Don't abuse useEffect `useEffect` is for synchronizing with external systems (APIs, timers, subscriptions, DOM measurements). It is not for deriving state from other state. ```tsx // Bad: syncing derived state through useEffect const [items, setItems] = useState([]); const [filteredItems, setFilteredItems] = useState([]); const [search, setSearch] = useState(""); useEffect(() => { setFilteredItems(items.filter((i) => i.name.includes(search))); }, [items, search]); // Good: derive during render const [items, setItems] = useState([]); const [search, setSearch] = useState(""); const filteredItems = items.filter((i) => i.name.includes(search)); ``` The `useEffect` version is worse in every way: it causes an extra re-render (state update inside an effect), it's harder to follow, and it introduces a frame where `filteredItems` is stale. **Rules of thumb:** - If you can compute it from existing state/props during render, do that. No `useEffect`, no `useMemo` (unless profiler says otherwise). - If you're setting state inside a `useEffect` that depends on other state, you almost certainly have a derived-state problem. Remove the effect and compute the value inline. - Reserve `useEffect` for genuine side effects: fetching data, starting subscriptions, measuring DOM elements, integrating with non-React code. --- ## 5. Complex cross-component state State colocation (section 2) is the default. When multiple distant components genuinely need to read and write the same state — multi-step wizards, builder UIs, coordinated panels — use a colocated store with **typed selectors** to keep the re-render blast radius small. ```ts // FeatureX/store.ts import { create } from "zustand"; interface WizardState { step: number; data: Record; next(): void; back(): void; setField(key: string, value: unknown): void; } export const useWizardStore = create((set) => ({ step: 0, data: {}, next: () => set((s) => ({ step: s.step + 1 })), back: () => set((s) => ({ step: Math.max(0, s.step - 1) })), setField: (key, value) => set((s) => ({ data: { ...s.data, [key]: value } })), })); ``` Consume via selectors — each component subscribes to only the slice it needs, so it only re-renders when that slice changes: ```tsx function WizardFooter() { const step = useWizardStore((s) => s.step); const next = useWizardStore((s) => s.next); const back = useWizardStore((s) => s.back); // Only re-renders when `step`, `next`, or `back` change return (
); } ``` **Guidelines:** - Colocate the store with the feature (`FeatureX/store.ts`), not in a global `stores/` directory. - Stores hold state and pure actions. Keep effects and API calls in hooks. - If only 2-3 components share state and they're in the same subtree, prefer lifting state to a common parent over introducing a store. --- ## 6. Memoization: measure first, optimize second ### The memoization trap Developers see slowness, assume it's re-renders, blanket the codebase with `React.memo` / `useMemo` / `useCallback`, and ship. The app stays slow because the real bottleneck was something else entirely: - API calls firing on every keystroke (add a debounce) - Unoptimized images (compress or lazy-load them) - Too many DOM nodes (virtualize long lists) - CSS animations triggering layout recalculation - Expensive synchronous work blocking the main thread **Workflow:** Open React DevTools Profiler, reproduce the slow interaction, read the flame graph. If no component exceeds ~16 ms, re-renders are not your problem. ### When memoization is justified | Situation | Tool | Why | |---|---|---| | Component renders 50 ms+ with unchanged props and re-renders often | `React.memo` | Skips calling the component function when props are shallow-equal | | Genuinely expensive derived data (thousands of items, complex transforms) | `useMemo` | Caches the computation across renders | | Stabilizing a callback passed to an already-memoized child | `useCallback` | Prevents breaking the child's memo check | | Context provider value is a new object reference every render | `useMemo` on the value | Prevents all consumers from re-rendering | ### When memoization is not justified - A component renders 3 divs. The memo check costs more than the render. - `.filter()` on 200 items. That's sub-millisecond work. - "Just in case" — you're adding complexity without evidence. - `useCallback` on a handler that isn't passed to a memoized child. It does nothing useful. ### The memo/useCallback chain `useCallback` only matters when the callback is passed to a `React.memo` child. Without memo on the child, stabilizing the reference is pointless. Without `useCallback` on the handler, the memo on the child is defeated. They are a pair — don't use one without the other. ```tsx const MemoizedChild = React.memo(({ onClick }: { onClick: () => void }) => { return ; }); // Breaks memoization: new function every render handleClick()} />; // Works: stable reference const handleClick = useCallback(() => doSomething(), []); ; ``` --- ## 7. Context pitfalls Every component that calls `useContext(SomeContext)` re-renders when the context **value reference** changes. If the provider creates a new object on every render, all consumers re-render — even if the object's contents are identical. ```tsx // Bad: new object reference every render const App = () => { const [theme, setTheme] = useState("light"); const value = { theme, setTheme }; // new ref each time return ( ); }; // Good: stable reference when theme hasn't changed const App = () => { const [theme, setTheme] = useState("light"); const value = useMemo(() => ({ theme, setTheme }), [theme]); return ( ); }; ``` For contexts that carry both rarely-changing data and frequently-changing data, split them into separate providers so consumers of the stable data don't re-render when the volatile data changes. --- ## 8. Common real-world bottlenecks Before blaming React, check these first: | Symptom | Likely cause | Fix | |---|---|---| | Typing in a search box feels laggy | API/analytics call on every keystroke | Debounce the call (200-300 ms) | | Page feels slow on mount | Large unoptimized images loading synchronously | Compress, lazy-load, use appropriate formats | | Scrolling a long list janks | Too many DOM nodes | Virtualize with a windowing library | | Interaction feels frozen for 100 ms+ | Synchronous heavy computation in render | Move to a web worker or break into chunks | | Everything re-renders when theme toggles | Context value is an unstable reference | `useMemo` the provider value | --- ## 9. Rules for this codebase ### Performance 1. **No preemptive memoization.** Do not add `React.memo`, `useMemo`, or `useCallback` without a profiler measurement showing the need. 2. **State locality first.** Default to colocating state with the component that uses it. Lift only when a sibling genuinely needs the same value. 3. **Composition over optimization hooks.** Restructure the component tree before reaching for memoization. Moving state down or extracting a wrapper component is almost always simpler and more effective. 4. **Debounce external calls.** Any callback that triggers a network request or heavy computation on user input must be debounced or throttled. Never fire API calls on every keystroke. 5. **Profile before and after.** If you add an optimization, include the before/after profiler numbers in the PR description. If you can't measure the difference, revert the optimization. 6. **Trust React's reconciler.** Re-renders are cheap. The virtual DOM diff is fast. A 50-component tree re-rendering in under 10 ms is normal and not a problem. Optimize based on data, not fear. 7. **Lazy-load heavy assets.** Images, large data visualizations, and code-split routes should load on demand, not block initial render. ### Structure 8. **Separate render from behavior.** When a component has non-trivial logic, split into `Component.tsx` (JSX) and `useComponent.ts` (hook). Keep the hook as the single entry point for state, effects, and callbacks. Push pure computation into a sibling `helpers.ts`. 9. **No useEffect for derived state.** If a value can be computed from existing state or props during render, compute it inline. Do not synchronize it through a `useEffect` + `setState` cycle. 10. **Use selectors for shared stores.** When a Zustand (or similar) store is necessary, every consumer must subscribe via a selector for the specific slice it needs. Never `useStore()` without a selector — that re-renders on every store change. 11. **Colocate everything.** Sub-components, hooks, helpers, and stores live next to the feature that owns them. Only promote to a shared location (`src/components/`, `src/lib/`) when a second consumer exists. 12. **Prefer function declarations** for components and named handlers. Reserve arrow functions for short inline callbacks (`.map`, `.filter`). 13. **Use early returns** to flatten conditional render logic. Avoid deeply nested ternaries in JSX. --- ## Summary mental model ``` State changes --> Owner re-renders --> All children re-render (props don't matter) Composition --> Keep state low, keep blast radius small (the real performance tool) Structure --> useX.ts owns behavior, X.tsx owns JSX (derive values during render, not via useEffect) Shared state --> Colocated store + typed selectors (each consumer subscribes to its slice only) React.memo --> Bails out early if props are shallow-equal (useful only when proven necessary) ``` Write simple code. Separate render from behavior. Derive values inline. Measure when something feels slow. Optimize what the profiler tells you to. That's it.