Core Solution
Building a resilient component architecture requires explicit boundary definitions. We will construct a QueryDashboard that demonstrates proper separation: configuration and server-driven data flow through props, while interaction and presentation toggles remain in local state.
Step 1: Define the External Contract (Props)
Props should describe what the component needs to render, not how it behaves internally. We use TypeScript interfaces to enforce immutability and clarify data origins.
interface QueryConfig {
endpoint: string;
maxResults: number;
defaultSort: 'asc' | 'desc';
}
interface DashboardProps {
config: QueryConfig;
onExport: (data: SearchResult[]) => void;
isLoading: boolean;
}
Step 2: Isolate Ephemeral Interaction (State)
Local state should only track values that change due to user interaction within the component's scope. We avoid mirroring props and instead compute derived values during render.
import { useState, useMemo, useCallback } from 'react';
interface SearchResult {
id: string;
title: string;
relevance: number;
}
export function QueryDashboard({ config, onExport, isLoading }: DashboardProps) {
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [pageOffset, setPageOffset] = useState(0);
// Derived state: computed during render, never stored
const filteredResults = useMemo(() => {
// Simulated filtering logic based on activeFilters
return [];
}, [activeFilters]);
const handleFilterToggle = useCallback((filterId: string) => {
setActiveFilters(prev =>
prev.includes(filterId)
? prev.filter(f => f !== filterId)
: [...prev, filterId]
);
}, []);
const handleExport = useCallback(() => {
onExport(filteredResults);
}, [filteredResults, onExport]);
return (
<div className="dashboard">
<FilterBar
filters={activeFilters}
onToggle={handleFilterToggle}
/>
<ResultsGrid
data={filteredResults}
sortDirection={config.defaultSort}
/>
<Pagination
offset={pageOffset}
onAdvance={() => setPageOffset(prev => prev + config.maxResults)}
/>
<button onClick={handleExport} disabled={isLoading}>
Export Results
</button>
</div>
);
}
Step 3: Architectural Rationale
- Props as Configuration:
config, isLoading, and onExport are passed down. The component cannot modify them, ensuring predictable data flow. This aligns with React's unidirectional data flow principle.
- State for Interaction:
activeFilters and pageOffset are local because they only affect this component's presentation. Lifting them would force parent components to manage UI-specific concerns, violating separation of concerns.
- Derived Values Over Synced State: Instead of copying
config.defaultSort into useState, we read it directly during render. This eliminates synchronization bugs and reduces memory overhead.
- Callback Stability:
useCallback wraps event handlers to prevent unnecessary child re-renders when passed as props. This is critical for performance in larger trees.
Pitfall Guide
1. Mirroring Props in Local State
Explanation: Copying a prop into useState creates two sources of truth. When the parent updates the prop, the child's state remains stale unless manually synced with useEffect.
Fix: Read props directly during render. If transformation is needed, compute it inline or with useMemo. Only use state if the value must diverge from the prop after initialization.
2. Over-Lifting State
Explanation: Hoisting state to a common ancestor when only one child needs it forces the parent to manage UI concerns and triggers unnecessary re-renders across siblings.
Fix: Keep state as close to the consuming component as possible. Only lift state when multiple components require synchronized access to the same value.
3. Passing Inline Objects/Functions as Props
Explanation: Creating new object or function references on every render breaks React's reference equality checks, causing child components to re-render even when logical data hasn't changed.
Fix: Extract static objects outside the component, use useMemo for computed props, and wrap callbacks with useCallback. Consider React.memo for pure presentation components.
4. Treating State as a Database
Explanation: Storing normalized server data, caches, or complex relational structures in useState leads to manual synchronization, memory leaks, and inconsistent UI.
Fix: Use dedicated data-fetching libraries (React Query, SWR, RTK Query) for server state. Reserve useState for ephemeral UI state like modals, form inputs, and toggle switches.
5. Ignoring Re-render Boundaries
Explanation: Passing large objects or arrays as props without memoization causes deep equality checks to fail, triggering full subtree reconciliation.
Fix: Structure props to pass primitive values or stable references. Split large components into smaller, memoized units. Use React DevTools Profiler to identify render bottlenecks.
6. State Synchronization Drift
Explanation: When multiple components manage related state independently (e.g., a search input and a filter dropdown), they can fall out of sync, producing inconsistent UI states.
Fix: Establish a single source of truth. Either lift the related state to a common parent or use a context/store pattern. Ensure derived values are computed, not duplicated.
7. Mutating Props Directly
Explanation: Attempting to modify prop values inside a child component violates React's immutability contract and causes unpredictable behavior, especially in concurrent mode.
Fix: Treat props as read-only. If mutation is required, pass a callback function to the parent and let it update the source. Never reassign or mutate prop objects directly.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Ephemeral UI toggle (modal, dropdown) | useState | Isolated lifecycle, no external dependencies | Minimal render overhead |
| Data shared across siblings | Lifted state or Context | Single source of truth prevents sync drift | Moderate parent re-renders |
| Server-driven configuration | Props | Immutable, predictable, testable | Zero local state cost |
| Form input with validation | useState + custom hook | Encapsulates logic, isolates re-renders | Low, scales with form complexity |
| Cached API response | External data layer | Handles deduplication, background updates | Higher initial setup, lower long-term cost |
Configuration Template
// types.ts
export interface ComponentConfig {
theme: 'light' | 'dark';
maxConcurrency: number;
retryAttempts: number;
}
export interface ComponentProps {
config: ComponentConfig;
onAction: (payload: Record<string, unknown>) => void;
isDisabled: boolean;
}
// Component.tsx
import { useState, useMemo, useCallback } from 'react';
import { ComponentProps } from './types';
export function ProductionComponent({ config, onAction, isDisabled }: ComponentProps) {
const [interactionCount, setInteractionCount] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const displayConfig = useMemo(() => ({
...config,
formattedTheme: config.theme.toUpperCase(),
}), [config]);
const handleTrigger = useCallback(() => {
if (isDisabled || isProcessing) return;
setIsProcessing(true);
setInteractionCount(prev => prev + 1);
onAction({
source: 'component',
count: interactionCount + 1,
timestamp: Date.now()
});
setTimeout(() => setIsProcessing(false), 300);
}, [isDisabled, isProcessing, interactionCount, onAction]);
return (
<section aria-busy={isProcessing}>
<header>
<h2>Theme: {displayConfig.formattedTheme}</h2>
<span>Concurrency: {displayConfig.maxConcurrency}</span>
</header>
<button
onClick={handleTrigger}
disabled={isDisabled || isProcessing}
>
{isProcessing ? 'Processing...' : `Execute (${interactionCount})`}
</button>
</section>
);
}
Quick Start Guide
- Define Boundaries: List all data points your component needs. Tag each as
external (props) or internal (state).
- Type the Contract: Create TypeScript interfaces for props. Ensure no prop is marked as mutable.
- Implement State Isolation: Add
useState only for values that change via user interaction within the component. Compute everything else during render.
- Stabilize References: Wrap callbacks with
useCallback and derived objects with useMemo before passing them down.
- Verify Render Scope: Run React DevTools Profiler, trigger interactions, and confirm that only the intended components re-render. Adjust boundaries if unnecessary subtrees update.