tchProps {
defaultState?: boolean;
onToggle?: (isActive: boolean) => void;
}
export const FeatureSwitch = ({ defaultState = false, onToggle }: FeatureSwitchProps) => {
const [isEnabled, setIsEnabled] = useState<boolean>(defaultState);
const handleToggle = useCallback(() => {
setIsEnabled((prev) => {
const nextState = !prev;
onToggle?.(nextState);
return nextState;
});
}, [onToggle]);
return (
<button
type="button"
onClick={handleToggle}
aria-pressed={isEnabled}
className={switch ${isEnabled ? 'active' : 'inactive'}}
>
{isEnabled ? 'ENABLED' : 'DISABLED'}
</button>
);
};
**Architecture Rationale:** Using a functional updater (`prev => !prev`) prevents stale state during rapid clicks. The `onToggle` callback is memoized with `useCallback` to avoid unnecessary re-renders in parent components. `aria-pressed` ensures accessibility compliance without extra state.
### Pattern 2: Controlled Text Binding
Controlled inputs synchronize React state with the DOM value. This pattern enables real-time validation, formatting, and server-side hydration consistency.
```typescript
interface QueryInputProps {
placeholder?: string;
onQueryChange?: (value: string) => void;
}
export const QueryInput = ({ placeholder = 'Search...', onQueryChange }: QueryInputProps) => {
const [query, setQuery] = useState<string>('');
const handleInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
setQuery(newValue);
onQueryChange?.(newValue);
}, [onQueryChange]);
return (
<input
type="text"
value={query}
onChange={handleInput}
placeholder={placeholder}
className="search-field"
/>
);
};
Architecture Rationale: The value attribute enforces a single source of truth. React controls the input, preventing DOM drift. The onChange handler extracts event.target.value and propagates it upward. This pattern is mandatory for forms that require validation, debounced API calls, or multi-step workflows.
Pattern 3: Conditional Visibility Toggling
Visibility toggles leverage boolean state with short-circuit evaluation. The pattern avoids mounting/unmounting overhead when the hidden content is lightweight.
interface DisclosurePanelProps {
title: string;
children: React.ReactNode;
}
export const DisclosurePanel = ({ title, children }: DisclosurePanelProps) => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const toggleVisibility = useCallback(() => {
setIsVisible((prev) => !prev);
}, []);
return (
<section className="disclosure">
<button type="button" onClick={toggleVisibility}>
{title} {isVisible ? '▲' : '▼'}
</button>
{isVisible && (
<div className="disclosure-content">
{children}
</div>
)}
</section>
);
};
Architecture Rationale: The && operator conditionally renders the child tree. This approach is optimal when the hidden content is inexpensive to keep in memory. If the content contains heavy computations or network requests, consider unmounting via conditional routing or Suspense boundaries instead.
Pattern 4: Derived Metric Computation
Derived values should never live in useState. Computing them during render eliminates double-updates and guarantees consistency.
interface MetricDisplayProps {
inputValue: string;
maxLength?: number;
}
export const MetricDisplay = ({ inputValue, maxLength = 280 }: MetricDisplayProps) => {
const characterCount = inputValue.length;
const isOverLimit = characterCount > maxLength;
return (
<span className={`metric ${isOverLimit ? 'error' : 'normal'}`}>
{characterCount}/{maxLength}
</span>
);
};
Architecture Rationale: inputValue.length is computed synchronously during render. No state updater is invoked, so React performs zero additional reconciliation cycles. The isOverLimit flag is also derived, ensuring the UI reflects the exact boundary condition without lag.
Pitfall Guide
1. Storing Derived State in useState
Explanation: Developers frequently create a second state variable to hold computed values (e.g., const [count, setCount] = useState(0) alongside const [length, setLength] = useState(0)). This triggers two state updates per interaction, causing unnecessary renders and potential race conditions.
Fix: Compute derived values directly in the component body or wrap them in useMemo if the calculation is expensive. Remove the secondary state variable entirely.
2. Direct State Mutation
Explanation: Mutating arrays or objects in state (e.g., state.items.push(newItem)) bypasses React's change detection. The reference remains identical, so React skips re-rendering, leaving the UI out of sync.
Fix: Always return a new reference. Use spread syntax ([...state.items, newItem]) or immutable update utilities. For complex objects, consider useImmer or structured cloning.
3. Missing Controlled Component Warnings
Explanation: Mixing value and defaultValue on the same input, or switching between controlled and uncontrolled patterns during the component lifecycle, triggers React warnings and breaks hydration.
Fix: Commit to one pattern per component lifecycle. If validation requires temporary uncontrolled behavior, manage it via refs and sync to state explicitly on submit.
4. Stale Closures in Event Handlers
Explanation: Event handlers capture state values at the time of creation. If dependencies change but the handler isn't updated, it operates on outdated values, leading to incorrect toggles or submissions.
Fix: Use functional updaters (setState(prev => ...)) or include all dependencies in useCallback/useEffect. Prefer functional updates for state that depends on its previous value.
Explanation: Creating multiple useState calls for logically connected data (e.g., firstName, lastName, email as separate hooks) fragments updates and complicates validation. Each setter triggers an independent render cycle.
Fix: Group related data into a single object state. Use a unified updater function or a custom hook to manage batched updates. Consider form libraries for complex schemas.
6. Ignoring React 18 Automatic Batching
Explanation: Developers sometimes wrap state updates in setTimeout or promises to force synchronous rendering, breaking React 18's automatic batching. This increases layout thrashing and reduces throughput.
Fix: Trust React's batching for synchronous and promise-based updates. Only use flushSync when DOM measurement is strictly required before the next paint.
7. Unnecessary Re-renders from Primitive State
Explanation: Updating state with the same primitive value (e.g., setState('active') when it's already 'active') still triggers a render in older React versions. While React 18 optimizes this, explicit checks prevent wasted cycles in legacy codebases.
Fix: Add guard clauses (if (value === currentState) return;) or rely on React 18's reference equality checks. Profile with React DevTools to verify actual render behavior.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple ON/OFF switch | useState<boolean> with functional updater | Minimal overhead, predictable toggle behavior | Negligible |
| Real-time search input | Controlled useState<string> + onChange | Enables validation, debouncing, and SSR hydration | Low (memory + render) |
| Character/word counter | Inline computation (value.length) | Zero extra renders, always synchronized | None |
| Complex multi-field form | Structured object state or form library | Batched updates, shared validation context | Medium (initial setup) |
| Heavy hidden content | Conditional routing or Suspense | Prevents mounting expensive trees until needed | High (architecture shift) |
Configuration Template
// useControlledState.ts
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseControlledStateOptions<T> {
defaultValue?: T;
value?: T;
onChange?: (value: T) => void;
}
export function useControlledState<T>({
defaultValue,
value,
onChange,
}: UseControlledStateOptions<T>): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = useState<T>(defaultValue as T);
const previousValueRef = useRef<T>(isControlled ? value : internalValue);
const currentValue = isControlled ? value : internalValue;
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const resolved = typeof next === 'function' ? (next as (prev: T) => T)(currentValue) : next;
if (resolved !== previousValueRef.current) {
previousValueRef.current = resolved;
if (!isControlled) {
setInternalValue(resolved);
}
onChange?.(resolved);
}
},
[currentValue, isControlled, onChange]
);
useEffect(() => {
if (isControlled) {
previousValueRef.current = value as T;
}
}, [isControlled, value]);
return [currentValue, setValue];
}
Quick Start Guide
- Initialize the hook: Import
useControlledState and pass your initial value. The hook automatically handles controlled/uncontrolled switching.
const [query, setQuery] = useControlledState({ defaultValue: '' });
- Bind to input: Attach
value={query} and onChange={(e) => setQuery(e.target.value)} to your input element. React now controls the DOM node.
- Compute derived metrics: Calculate length, validation status, or formatting directly in the render body. No additional state required.
const isValid = query.length > 0 && query.length <= 280;
- Profile and iterate: Run React DevTools Profiler. Verify that typing triggers exactly one render cycle. If multiple components re-render, lift state or memoize handlers with
useCallback.