React Patterns

324 fragments · Layer 3 Synthesized established · 324 evidence · updated 2025-01-31
↓ MD ↓ PDF

Summary

The most expensive React bugs across these projects aren't architectural — they're predictable failure modes that recur in every codebase: hydration mismatches from third-party scripts, infinite re-renders from state updates inside useMemo, stale chunk errors after deployment, and undefined property access crashing components that were working fine until an API response changed shape. Component size is the single biggest predictor of maintenance cost: files above ~500 lines consistently accumulate bugs, resist testing, and get refactored at 62–94% size reduction. The patterns that hold up under real use are custom hooks for business logic extraction, Promise.allSettled for parallel data fetching, controlled (not default) state for persistent UI elements, and suppressHydrationWarning scoped tightly to the element injected by third-party scripts. Dark mode implemented via CSS class toggling causes flash-of-wrong-theme reliably enough that one project (Eydn) removed it entirely.


TL;DR

What we've learned
- State updates inside useMemo cause infinite re-renders — move them to event handlers
- Third-party scripts (Termly, analytics) cause hydration crashes; suppressHydrationWarning on the specific element is the fix, not a global suppression
- defaultValue on accordions and similar components collapses state on any re-render; use controlled value/onValueChange
- Components above ~500 lines are a maintenance liability — every refactor in this dataset reduced them by 62–94% and improved test reliability
- React 19 Server Components can't serialize component references; pass icon names as strings, not component objects
- useMemo and useCallback prevent re-renders in list items, but only when dependency arrays are correct — lint warnings here are real bugs, not noise

External insights

No external sources ingested yet for this topic.


Common Failure Modes

State updates inside useMemo cause infinite re-renders

Observed in OrbitABM's DataTable pagination component: a useMemo block that called a state setter triggered a render → recompute → state update → render loop. The component locked the browser tab.

// BROKEN — state update inside useMemo
const paginatedData = useMemo(() => {
  if (page > totalPages) setPage(1); // ← triggers re-render, which re-runs useMemo
  return data.slice(page * pageSize, (page + 1) * pageSize);
}, [data, page, pageSize]);

// FIXED — state update in event handler or useEffect
useEffect(() => {
  if (page > totalPages) setPage(1);
}, [page, totalPages]);

[1]


Third-party script injection causes hydration mismatch

Termly (cookie consent) injects DOM nodes during hydration that React didn't render on the server. The crash surfaces as:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

The fix is suppressHydrationWarning on the specific container element Termly targets — not on <body> or a high-level wrapper. Suppressing too broadly hides real hydration bugs.

Observed in Eydn [2]. The same pattern applies to any analytics or consent script that writes to the DOM before React hydrates.


Dark mode state initialized from localStorage causes hydration mismatch

When dark mode state is read synchronously from localStorage during render, the server renders with no theme class and the client renders with one — React flags the mismatch. The fix is to initialize the state as null, resolve it in useEffect, and render nothing (or a neutral state) until the effect runs.

// BROKEN
const [dark, setDark] = useState(
  typeof window !== 'undefined' && localStorage.getItem('theme') === 'dark'
);

// FIXED
const [dark, setDark] = useState<boolean | null>(null);
useEffect(() => {
  setDark(localStorage.getItem('theme') === 'dark');
}, []);
if (dark === null) return null; // or render without theme class

Observed in ClientBrain [3]. Eydn went further and removed dark mode entirely after repeated flash-of-wrong-theme issues [4] — CSS variable theming with useEffect resolution is the only approach that avoids the flash without removing the feature.


defaultValue on accordions collapses on re-render

Using defaultValue on Radix UI (or shadcn) Accordion means the open state is uncontrolled — any parent re-render resets it to the default. Users see their expanded section collapse when unrelated state changes.

// BROKEN — collapses on any parent re-render
<Accordion defaultValue="goals">

// FIXED — persists across re-renders
<Accordion value={openSection} onValueChange={setOpenSection}>

Observed in AsymXray's goals page [5]. The same issue applies to any Radix primitive that has both defaultValue and value props — tabs, selects, dialogs.


Stale chunk errors after deployment cause blank screens

After a new deployment, users with the old bundle cached get ChunkLoadError when navigating to a route whose chunk hash changed. Without recovery logic, the app shows a blank screen or an unhandled error boundary.

The fix is automatic recovery with a refresh cap to prevent infinite loops:

// In a top-level error boundary or global error handler
const CHUNK_ERROR_KEY = 'chunk_error_refresh_count';
const MAX_RETRIES = 2;
const WINDOW_MS = 30_000;

if (error instanceof Error && error.name === 'ChunkLoadError') {
  const now = Date.now();
  const stored = JSON.parse(sessionStorage.getItem(CHUNK_ERROR_KEY) || '{"count":0,"ts":0}');
  if (now - stored.ts > WINDOW_MS) {
    sessionStorage.setItem(CHUNK_ERROR_KEY, JSON.stringify({ count: 1, ts: now }));
    window.location.reload();
  } else if (stored.count < MAX_RETRIES) {
    sessionStorage.setItem(CHUNK_ERROR_KEY, JSON.stringify({ count: stored.count + 1, ts: stored.ts }));
    window.location.reload();
  }
  // else: show error UI, don't reload
}

Observed in AsymXray [6]. Without the counter, a broken chunk causes an infinite reload loop.


undefined API response properties crash components

Consistent across projects: an API response changes shape (field removed, renamed, or conditionally absent), and a component that previously worked throws:

TypeError: Cannot read properties of undefined (reading 'severity')
TypeError: Cannot read properties of undefined (reading 'map')

The pattern that prevents this is defensive access at the point of use, not at the API boundary:

// BROKEN
const severity = data.status.severity; // crashes if status is undefined

// FIXED
const severity = data?.status?.severity ?? 'unknown';
const alerts = data?.alerts ?? [];

Observed in AsymXray (status severity, alerts array) [7] and AsymXray monitoring [8]. Showing '--' instead of 'undefined %' is the UX fix; optional chaining is the code fix.


React 19 Server Components can't serialize component references

Passing a Lucide (or any) icon component as a prop from a Server Component to a Client Component fails in React 19 with a serialization error. Component references are not serializable across the server/client boundary.

// BROKEN — React 19 Server Component
import { Star } from 'lucide-react';
<MetricCard icon={Star} /> // ← serialization error

// FIXED — pass string name, resolve in client component
<MetricCard icon="Star" />

// In the client component:
import * as Icons from 'lucide-react';
const Icon = Icons[props.icon as keyof typeof Icons];

Observed in Stride v2 [9]. This is a React 19 behavior change — it worked in React 18 because the boundary enforcement was looser.


ClientOnly wrapper breaks navigation flows

Wrapping navigation-critical components in a ClientOnly guard (renders null on server, component on client) causes the component to be absent during the initial render pass. If navigation logic depends on that component being mounted, the navigation silently fails.

Observed in AsymXray's opportunity navigation [10]. The fix was removing the ClientOnly wrapper and handling the server-side case with conditional logic inside the component rather than outside it.


React Hooks rules violations in conditional rendering

Calling hooks after a conditional return violates the Rules of Hooks and causes runtime errors. This surfaces most often when adding early-return guards to existing components.

// BROKEN
function SurveyStep({ step }: Props) {
  if (!step) return null; // ← early return before hooks
  const [value, setValue] = useState('');
  // ...
}

// FIXED
function SurveyStep({ step }: Props) {
  const [value, setValue] = useState('');
  if (!step) return null;
  // ...
}

Observed in Hazardos mobile survey [11]. The ESLint react-hooks/rules-of-hooks rule catches this — if it's disabled or ignored, this will bite you.


When a modal's open state is stored in a URL parameter (e.g., ?opportunity=123), clicking the X button closes the modal visually but the URL still contains the parameter. On the next render cycle — or if the user copies the URL — the modal reopens.

Observed in AsymXray [12]. The fix is to clear the URL parameter in the close handler using router.replace (not router.push, which adds a history entry).


What Works

Custom hooks for business logic extraction

Extracting business logic from page components into custom hooks consistently improves testability and reduces duplication. In LabelCheck, extracting the analyze-page logic into a custom hook enabled unit testing of the business rules independently of the React rendering lifecycle [13]. In AsymXray, a useSettingsForm hook reduced per-settings-page code by 20–25% [14].

The pattern: hooks own state, derived values, and side effects. Components own layout and event wiring. When a component's useState count exceeds 4–5, it's a signal to extract a hook.


Promise.allSettled for parallel data fetching

Using Promise.all for parallel API calls means one failure blocks all data. Promise.allSettled lets each call fail independently, and null-safe field access on the results prevents render crashes from partial data.

const [companiesResult, contactsResult, activitiesResult] = await Promise.allSettled([
  fetchCompanies(orgId),
  fetchContacts(orgId),
  fetchActivities(orgId),
]);

const companies = companiesResult.status === 'fulfilled' ? companiesResult.value : [];
const contacts = contactsResult.status === 'fulfilled' ? contactsResult.value : [];

Observed in ContentCommand preventing a PPC intel tab crash from malformed data [15]. One bad API response no longer takes down the whole page.


Memoization for list components with filter state

In Hazardos, CustomerList was re-rendering on every filter change because filter state lived above the list and the list's callbacks were recreated each render. The fix: React.memo on the list item, useCallback on callbacks passed as props, useMemo on derived filter results, and 300ms debounce on the search input.

const filteredCustomers = useMemo(
  () => customers.filter(c => c.name.toLowerCase().includes(debouncedSearch)),
  [customers, debouncedSearch]
);

const handleSelect = useCallback((id: string) => {
  setSelected(prev => [...prev, id]);
}, []);

[16] and [17]. The debounce is load-bearing — without it, memoization doesn't help because the search string changes on every keystroke.


Key-based remounting for countdown/timer resets

When a component manages its own countdown or animation state internally, resetting it from outside requires either lifting state up or using the key prop to force remount. The key approach is simpler and avoids prop drilling.

const [retryKey, setRetryKey] = useState(0);

// Increment key to reset the countdown component
<RetryDialog key={retryKey} onRetry={() => setRetryKey(k => k + 1)} />

Observed in Stride v2's error recovery flow [18]. This pattern works for any component that needs to be "reset to initial state" without exposing internal state.


Inline styles for gradient text (not Tailwind utilities)

Tailwind's bg-clip-text + text-transparent + gradient utilities render inconsistently across browsers and Next.js versions. Inline styles with explicit WebKit properties work reliably everywhere.

// UNRELIABLE — Tailwind gradient text
<span className="bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text text-transparent">

// RELIABLE — inline styles
<span style={{
  background: 'linear-gradient(to right, #8b5cf6, #ec4899)',
  WebkitBackgroundClip: 'text',
  WebkitTextFillColor: 'transparent',
  backgroundClip: 'text',
}}>

Observed in Eydn (logo, pricing page) [19]. The Tailwind utilities work in isolation but break under certain purge configurations or when combined with other text utilities.


Debounced auto-save with differentiated delays

Auto-save with a single debounce delay works for simple fields. For complex forms with arrays and nested objects, use differentiated delays: shorter for scalar fields (1500ms), longer for arrays (2000ms) to avoid partial saves mid-edit.

Observed in Eydn's wedding website editor [20]. Pair with a visible save status indicator — users need to know their changes were persisted, especially on mobile where they might close the browser.


useEffect must include an initial fetch call

A useEffect that only sets up a polling interval (or event listener) without an immediate initial call leaves the component in its initial state until the first interval fires. This is especially visible in monitoring dashboards where the first render shows stale zeros.

// BROKEN — waits 30s before first data
useEffect(() => {
  const interval = setInterval(fetchHealth, 30_000);
  return () => clearInterval(interval);
}, []);

// FIXED — fetches immediately, then polls
useEffect(() => {
  fetchHealth(); // ← initial call
  const interval = setInterval(fetchHealth, 30_000);
  return () => clearInterval(interval);
}, []);

Observed in AsymXray [21].


Gotchas and Edge Cases

recharts skips axis labels by default

Recharts' XAxis and YAxis skip labels when the chart is too narrow to fit them all — the default interval is 'auto'. For bar charts where every label must be visible (e.g., vendor categories), set interval={0} and use dynamic chart height (36px * barCount, min 300px).

Observed in Eydn's vendor category chart [22].


Database enum values must match UI color map keys exactly

When mapping database enum values to badge colors or chart colors, the enum format matters. A database column using in_progress (underscore) won't match a color map keyed on inProgress (camelCase) or In Progress (display format). The result is every status showing the default/fallback color.

Observed in Hazardos pie chart [23]. Keep color maps keyed on the raw database value, and format for display separately.


Lazy loading above-the-fold images hurts LCP

Adding loading="lazy" to images that are visible on initial render delays their load, worsening Largest Contentful Paint. Lazy loading only helps for images below the fold.

Observed in LabelCheck [24] — the "quick win" turned out to be a no-op because the app had a single image that was always above the fold.


type="number" inputs are poor for currency

The native number input strips leading zeros, rejects comma-formatted values, and shows browser-native spinners. For currency fields, use type="text" with inputMode="numeric" and handle formatting in the onChange handler.

Observed in Eydn's budget page [25].


Auto-scroll on mount scrolls to bottom when message list is empty

A useEffect that calls scrollToBottom() on mount will scroll to the bottom of an empty container, which is visually correct for a chat but wrong for a page that starts with no messages — it pushes the input field off-screen.

Observed in Eydn's Ask Eydn page [26]. Guard the auto-scroll: only fire when the message list has content.


Fuzzy matching direction matters for allergen detection

In LabelCheck's allergen detection, checking whether a derivative string contains the ingredient name (rather than the ingredient containing the derivative) caused false positives. The correct check is: does the ingredient text contain the allergen derivative?

// WRONG — false positives
if (derivative.includes(ingredient)) { ... }

// CORRECT
if (ingredient.toLowerCase().includes(derivative.toLowerCase())) { ... }

[27]. The direction of the includes check is the entire bug.


Smart task cascading must distinguish task origin

In Eydn's wedding planning app, auto-shifting tasks when the wedding date changes should only apply to system-generated incomplete tasks. User-created tasks and already-completed tasks must not shift — users have made explicit decisions about those dates.

[28]. The fix was adding an is_system_generated flag and checking status !== 'completed' before applying the cascade.


Tab visibility should be config-driven, not data-driven

Hiding a tab when its data is empty (e.g., no e-commerce data yet) confuses users who expect the tab to exist. Showing the tab with a helpful "data not available" message is better UX than making the tab appear and disappear based on data state.

Observed in AsymXray [29].


Where Docs Disagree With Practice

React 19 Server Component boundary enforcement is stricter than documented

React 19 docs describe the server/client boundary but don't prominently warn that component references (not just functions) are non-serializable. In React 18, passing a Lucide icon component as a prop from a Server Component worked because the boundary was enforced less strictly. In React 19, it throws at runtime.

Observed in Stride v2 [9]. The workaround (string names resolved in the client component) is not in the official migration guide.


Dark mode with CSS variables does not prevent flash without useEffect initialization guard

The standard advice for dark mode is to use CSS variables and a class on <html>. In practice, without initializing the theme state as null and resolving it in useEffect, the server renders with no theme class and the client applies the class after hydration — causing a visible flash. Eydn's team removed dark mode entirely rather than maintain the initialization guard [4]. The null initialization pattern in ClientBrain [3] is the correct fix, but it requires rendering nothing (or a skeleton) until the effect resolves.


QueryClient garbage collection is not configured by default

TanStack Query's default gcTime (formerly cacheTime) is 5 minutes, which is fine for small apps but causes unbounded memory growth in dashboards with many queries. The docs mention gcTime but don't recommend a default. In AsymXray, explicit gcTime configuration was added as part of a performance fix alongside N+1 query consolidation [30].


prefers-reduced-motion is not applied automatically by animation libraries

CSS animation libraries and Tailwind's animate-* utilities don't automatically respect prefers-reduced-motion. The media query must be applied explicitly around keyframe definitions.

@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  .animate-fade-in { animation: none; }
}

Observed in Eydn [31]. This is a WCAG 2.1 requirement, not a nice-to-have.


Tool and Version Notes



Sources

Synthesized from 324 fragments: git commits across AsymXray, ClientBrain, ContentCommand, Eydn, Hazardos, LabelCheck, OrbitABM, and Stride v2. No external sources ingested yet. Date range: unknown to unknown.

Sources

  1. Orbitabm E064C7E Infinite Re Render In Datatable Pagination
  2. Eydn App 4C09Bea Fix React Hydration Crash Caused By Termly Script
  3. Client Brain 26Acc5F Fix Hydration Mismatch In Nav Dark Mode Toggle
  4. Eydn App 976B33F Remove Dark Mode Light Mode Only
  5. Asymxray 4936E1D Fix Accordion State Persistence On Goals Page
  6. Asymxray 4Bd891B Add Automatic Chunk Error Recovery For Stale Deplo
  7. Asymxray B07Fc26 Add Defensive Checks For Undefined Statusseverity, Asymxray F0Ee301 Handle Undefined Alerts Array In Agency Dashboard
  8. Asymxray 9E172B1 Prevent Objectvalues Error On Undefined In Monitor
  9. Stride V2 1985A04 Resolve React 19 Server Component Serialization Is
  10. Asymxray Ce97D7E Fix Opportunity Navigation And Improve Top Opportu
  11. Hazardos 7185Ac4 Fix React Hooks Rules Violations In Mobile Survey
  12. Asymxray 57Eb0C2 Fix Opportunity Modal Close And Clean Up Incomplete Gbp Integration
  13. Labelcheck Eceaae4 Extract Business Logic From Analyze Page Into Cust
  14. Asymxray Ff2B606 Refactor Settings Pages And Fix Lint Errors
  15. Contentcommand D6B500D Prevent Ppc Intel Tab Crash From Missingmalformed
  16. Hazardos F2Eb565 Fix Customerlist Re Rendering On Every Filter Chan
  17. Stride V2 D697Daa Add Reactmemo And Usecallback To List Item Compone
  18. Stride V2 00E734D Improve Error Recovery Mechanisms
  19. Eydn App 9A7C993 Fix Gradient Text On Eydn Logo In Header And Sideb, Eydn App 2B2Bef3 Fix Gradient Text On Price Display Homepage Pricin
  20. Eydn App D1B0239 Add Auto Save Url Checker And Schedule Import To W
  21. Asymxray E6B3Ac0 Add Initial Fetchsystemhealth Call On Mount
  22. Eydn App 77D302E Fix Vendor Category Chart Show All Labels Dynamic
  23. Hazardos Dec15B4 Match Pie Chart Colors To Actual Job Status Values
  24. Labelcheck 50B81A4 Add Lazy Loading To Image Preview Quick Win 5 Mi
  25. Eydn App 548Bccd Fix Budget Currency Formatting And Add Guest Count
  26. Eydn App 70Cf7A5 Fix Ask Eydn Page Scrolling To Bottom With
  27. Labelcheck 00407E7 Fix Supplement Analysis Ui And Allergen Display Is
  28. Eydn App A1934Da Smart Task Cascading Auto Shift Milestones Flag Ap
  29. Asymxray 885030D Show E Commerce Tab Based On Visibility Config Not
  30. Asymxray 4461770 Fix N1 Query Patterns And Add Queryclient Gc Confi
  31. Eydn App 9D81E47 Fix 6 Landing Page Qa Items
  32. Stride V2 648D0Fa Add Client Side Image Optimization For Uploads

Fragments (324)