cursor persistence.
Architecture Decisions
- Action-Driven State Transitions: Complex state with multiple interdependent fields benefits from a reducer pattern. Actions explicitly declare intent (
SAVE_DRAFT, UPDATE_CONTENT, RESET_VERSION), making state transitions predictable and testable.
- Immutable Updates: The reducer always returns a new object reference. This guarantees React's reconciliation algorithm detects changes and avoids stale closure bugs.
- Render-Agnostic Persistence: The auto-save timer and previous content snapshot are stored in
useRef. They do not affect the UI directly, so triggering re-renders would waste CPU cycles.
- Type Safety: Strict TypeScript interfaces prevent payload mismatches and enforce reducer purity.
Implementation
import { useReducer, useRef, useCallback, useEffect } from 'react';
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// TYPE DEFINITIONS
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface EditorState {
content: string;
version: number;
isDirty: boolean;
lastSaved: number | null;
}
type EditorAction =
| { type: 'UPDATE_CONTENT'; payload: string }
| { type: 'SAVE_DRAFT' }
| { type: 'RESET_VERSION' };
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// REDUCER LOGIC
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const initialState: EditorState = {
content: '',
version: 1,
isDirty: false,
lastSaved: null,
};
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case 'UPDATE_CONTENT':
return {
...state,
content: action.payload,
isDirty: true,
};
case 'SAVE_DRAFT':
return {
...state,
isDirty: false,
lastSaved: Date.now(),
version: state.version + 1,
};
case 'RESET_VERSION':
return {
...initialState,
content: state.content,
};
default:
return state;
}
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// COMPONENT IMPLEMENTATION
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function DocumentEditor() {
const [state, dispatch] = useReducer(editorReducer, initialState);
// Persistent references that do not trigger re-renders
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousContentRef = useRef<string>('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Throttled auto-save mechanism
const scheduleAutoSave = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(() => {
dispatch({ type: 'SAVE_DRAFT' });
previousContentRef.current = state.content;
}, 2000);
}, [state.content]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
dispatch({ type: 'UPDATE_CONTENT', payload: newValue });
scheduleAutoSave();
};
const handleManualSave = () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
dispatch({ type: 'SAVE_DRAFT' });
previousContentRef.current = state.content;
};
const handleReset = () => {
dispatch({ type: 'RESET_VERSION' });
previousContentRef.current = '';
textareaRef.current?.focus();
};
return (
<div className="editor-container">
<header>
<span>Version: {state.version}</span>
<span>{state.isDirty ? 'β Unsaved' : 'β Saved'}</span>
</header>
<textarea
ref={textareaRef}
value={state.content}
onChange={handleInputChange}
placeholder="Start typing..."
rows={12}
/>
<div className="controls">
<button onClick={handleManualSave} disabled={!state.isDirty}>
Save Now
</button>
<button onClick={handleReset}>Reset Editor</button>
</div>
<footer>
<small>Previous content length: {previousContentRef.current.length}</small>
</footer>
</div>
);
}
Why This Architecture Works
- Separation of Concerns: UI state (
content, version, isDirty) lives in the reducer. Side-effect state (saveTimerRef, previousContentRef) lives in refs. This prevents accidental re-renders when timer IDs or historical snapshots change.
- Deterministic Transitions: Every state change flows through
dispatch. The reducer is a pure function, making it trivial to unit test without mounting components.
- Performance Optimization: The auto-save timer is managed via
useRef. Clearing and resetting the timer does not trigger a render cycle. Only when SAVE_DRAFT is dispatched does React update the DOM.
- DOM Stability:
textareaRef maintains a stable reference to the DOM node across renders. Calling .focus() or reading .selectionStart never interferes with React's reconciliation.
Pitfall Guide
1. Expecting useRef Mutations to Trigger Re-renders
Explanation: Developers frequently update ref.current and expect the UI to reflect the change. Refs are explicitly designed to bypass React's rendering cycle.
Fix: Only mutate refs for non-visual data. If the UI must update, store the value in state or dispatch a reducer action.
2. Using useReducer for Simple Boolean Toggles
Explanation: Wrapping a single true/false value in a reducer adds unnecessary boilerplate and obscures intent.
Fix: Reserve useReducer for state objects with 2+ interdependent fields or complex transition logic. Use useState for primitives.
3. Storing DOM Nodes in useState
Explanation: Assigning ref.current to state causes infinite render loops or stale node references because React recreates DOM nodes during reconciliation.
Fix: Always use useRef for DOM handles. Read or manipulate them imperatively inside effects or event handlers.
4. Ignoring Closure Staleness in Async Callbacks
Explanation: Reading ref.current inside setTimeout or fetch callbacks can capture outdated values if the ref is reassigned before the callback executes.
Fix: Capture the value at the time of scheduling, or use a stable ref pattern. For state, rely on functional updates (setState(prev => ...)).
5. Mutating State Objects Directly Inside Reducers
Explanation: Returning the same object reference after mutating properties breaks React's change detection. The UI will not update.
Fix: Always return a new object. Use spread syntax or immutable utilities. Never modify state or action.payload directly.
6. Using useRef as a Global State Substitute Without Pub/Sub
Explanation: Storing application-wide data in a ref inside a component makes it inaccessible to other components and breaks React's data flow.
Fix: Use Context, Zustand, or Redux for cross-component state. Keep useRef strictly local to the component's lifecycle.
7. Reinitializing Refs on Every Render
Explanation: Calling useRef() inside conditional blocks or loops violates the Rules of Hooks, causing unpredictable behavior and React warnings.
Fix: Always call hooks at the top level of the component. useRef only initializes once; subsequent renders return the existing container.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single toggle or counter | useState | Minimal boilerplate, direct setter, predictable render cycle | Low (negligible) |
| Form with validation, versioning, or interdependent fields | useReducer | Centralized transition logic, explicit actions, easier debugging | Medium (initial setup) |
| DOM node access, cursor position, or previous value tracking | useRef | Zero render overhead, stable identity across cycles | None |
| Cross-component data sharing | Context + useReducer or external store | Avoids prop drilling, maintains predictable data flow | Medium-High (architecture overhead) |
| High-frequency updates (animation frames, scroll tracking) | useRef + requestAnimationFrame | Bypasses React's render queue, prevents main thread blocking | Low (performance gain) |
Configuration Template
// usePersistentState.ts
import { useReducer, useRef, useCallback } from 'react';
type Reducer<S, A> = (state: S, action: A) => S;
interface UsePersistentStateOptions<S, A> {
reducer: Reducer<S, A>;
initialState: S;
persistKey?: string; // Optional: sync with localStorage/sessionStorage
}
export function usePersistentState<S, A>({
reducer,
initialState,
persistKey,
}: UsePersistentStateOptions<S, A>) {
const [state, dispatch] = useReducer(reducer, initialState);
const isInitialized = useRef(false);
const dispatchWithPersistence = useCallback(
(action: A) => {
dispatch(action);
if (persistKey && isInitialized.current) {
try {
localStorage.setItem(persistKey, JSON.stringify(state));
} catch {
// Silently fail storage quota or security errors
}
}
},
[persistKey, state]
);
// Hydrate from storage on mount
if (!isInitialized.current && persistKey) {
try {
const stored = localStorage.getItem(persistKey);
if (stored) {
const parsed = JSON.parse(stored) as S;
// Merge carefully to avoid schema drift
Object.assign(initialState, parsed);
}
} catch {
// Fallback to initialState
}
isInitialized.current = true;
}
return { state, dispatch: dispatchWithPersistence };
}
Quick Start Guide
- Identify Data Contracts: List every piece of data in your component. Mark each as
RENDER (affects UI) or PERSIST (side-effect, DOM, history).
- Initialize Hooks: Call
useReducer for RENDER data and useRef for PERSIST data at the top level of your component.
- Wire Event Handlers: Dispatch actions for state changes. Mutate refs directly for non-visual updates. Never mix the two.
- Add Cleanup: Use
useEffect to clear timers, cancel subscriptions, and nullify refs on unmount.
- Validate in DevTools: Open React DevTools, toggle "Highlight updates when components render", and verify that ref mutations do not trigger unnecessary render passes.