I've made my recent career on React apps. But React has a fundamental problem for complex apps: by reducing the world to representation as state variables, it removes the ability to scalably build dynamic interfaces. This opinion crystallized for me in the v7 rewrite of React Map GL from transition*
props to imperative direct control of the underlying map. Handling pan/zoom transitions while still accepting user touches had been impossible with the props-based approach.
I tested my opinion on Claude. Its initial response was 'respectful disagree', which was impressive. (Finally, we have an AI chat which can choose a contrary position and defend it.) By working through an example case together, it not only changed its mind, but proved my position better than I could by use of examples and excellent reasoning.
tldr;
The fundamental issue is that React's mental model - where UI is a pure function of state - actually obscures the temporal and causal relationships that are essential in truly dynamic interfaces. The verbosity of DOM manipulation isn't just necessary overhead - it's actually expressing something important: the precise choreography of when and how UI elements should respond to events.
In the real-time collaboration example, the core questions are temporal and causal:
- When did this change happen?
- Who initiated it?
- How should it be visually introduced to other users?
- How do we maintain user focus and context through the update?
These aren't naturally expressed as state transitions. Trying to force them into React's model requires increasingly complex state management that actually makes the code's intent less clear, not more clear.
The Proof
Here's the part of the conversation where React's weaknesses come to light:
// Break content into segments that preserve remote selections
const renderContent = () => {
// Somehow merge:
// - actual content
// - remote selections
// - local selections
// - recent change highlights
// All while maintaining correct cursor positions...
};
// Hope that React's reconciliation doesn't disrupt
// cursor positioning or selection rendering...
}
The React version becomes challenging because:
- We're fighting the abstraction - React wants to own the DOM update schedule, but we need precise control for cursor positions
- State interdependencies multiply - each new feature adds state that needs to be carefully synchronized with existing state
- The rendering logic becomes extremely complex as it tries to merge multiple concerns into a single state -> render transform
- Timing becomes implicit and hard to reason about - when exactly will cursor positions update relative to content changes?
The DOM version remains relatively clear because:
- Event handling is explicit
- State updates are synchronous and ordered
- DOM updates happen exactly when and how we specify
- New features mostly just add new event handlers and DOM manipulations, rather than complicated state interactions
This illustrates why direct DOM manipulation can be preferable for highly dynamic interfaces - the added verbosity is offset by better control and clearer causality.
Full Chat
Here is the full conversation, including the code which Claude wrote.
Prompt:
The primary problem with react is that it reduces UI updates to State transitions. For dynamic interfaces, this is inadequate. You actually need to know whether the state update was due to user input or new data received from an API or some local background process completing. React by its design removes that knowledge from the system. So react really only works well if your application is simple enough that you don't have to smoothly handle events of varying type.
Response:
This is an interesting perspective on React's state management model. I'd respectfully disagree (π) with the characterization that React removes knowledge of state update sources, though I understand the concern about handling complex event interactions.
React actually provides several ways to distinguish between different types of state updates and their sources:
- Event Handlers vs. Effects
- User interactions are typically handled in event handlers (onClick, onChange, etc.)
- API responses and background processes are usually managed in useEffect hooks
- These different contexts allow you to handle updates differently based on their source
function ExampleComponent() {
// User input handling
const handleClick = (e) => {
setState(newValue);
// Add specific UI feedback for user interaction
};
// API/background updates
useEffect(() => {
const fetchData = async () => {
const result = await api.getData();
setState(result);
// Handle background update differently
};
}, []);
}
- State Update Batching React 18 introduced automatic batching, but you can still control how updates are processed:
// Immediate update if needed
flushSync(() => {
setState(newValue);
});
- Transition API The useTransition hook specifically helps manage different types of updates:
const [isPending, startTransition] = useTransition();
// Urgent updates (user input)
handleDirectUpdate();
// Background updates
startTransition(() => {
handleBackgroundUpdate();
});
- Priority-based Updates React 18's concurrent features allow you to prioritize different types of updates:
// High priority
setValue(newValue);
// Lower priority
useDeferredValue(value);
Would you be interested in exploring specific scenarios where you've found React's state management model challenging? I'd be curious to hear about concrete cases where you've encountered limitations.
Prompt:
Let's say that a user saves a note. In response to that save request, the back end sends a different version which incorporates changes from another user. Meanwhile, the user has continued editing the note. In standard react, that new version would show up as a state variable change, causing re-rendering of the note, discarding the edits the user has made.
Response:
Ah, this is a great concrete example of concurrent edit conflicts! Yes, if implemented naively in React, this would lead to a poor user experience. However, this is more a matter of state management design than a fundamental limitation of React. Here's how we can handle this scenario:
function NoteEditor() {
// Server state - the last known saved version
const [savedNote, setSavedNote] = useState(initialNote);
// Local state - tracks current user edits
const [localEdits, setLocalEdits] = useState(null);
// Derived state - what we actually show in the editor
const activeContent = localEdits ?? savedNote.content;
const handleUserEdit = (newContent) => {
setLocalEdits(newContent);
};
const handleSave = async () => {
try {
const result = await saveNote({
...savedNote,
content: localEdits,
lastKnownVersion: savedNote.version // Include version for conflict detection
});
// If save successful, update saved state and clear local edits
setSavedNote(result);
setLocalEdits(null);
} catch (error) {
if (error.code === 'VERSION_CONFLICT') {
// Show conflict resolution UI
showConflictDialog({
serverVersion: error.serverNote,
localVersion: {
...savedNote,
content: localEdits
},
onResolve: (resolvedContent) => {
setLocalEdits(resolvedContent);
}
});
}
}
};
// Handle server-pushed updates
useEffect(() => {
const subscription = noteUpdates.subscribe(serverUpdate => {
if (!localEdits) {
// If no local edits, accept server update
setSavedNote(serverUpdate);
} else {
// If we have local edits, notify user and let them choose
showUpdateAvailableNotification({
serverVersion: serverUpdate,
localVersion: {
...savedNote,
content: localEdits
},
onAcceptServer: () => {
setSavedNote(serverUpdate);
setLocalEdits(null);
},
onKeepLocal: () => {
// Update saved base but preserve edits
setSavedNote(serverUpdate);
// Keep localEdits as is
}
});
}
});
return () => subscription.unsubscribe();
}, [localEdits]);
return (
<div>
<textarea
value={activeContent}
onChange={e => handleUserEdit(e.target.value)}
/>
<button onClick={handleSave}>Save</button>
{localEdits && <div>Unsaved changes</div>}
</div>
);
}
Key concepts in this solution:
- Separate state tracking:
-
savedNote
: The last known server state -
localEdits
: Current user modifications - We only show server updates if there are no local edits
-
- Version tracking:
- Include version numbers in saves to detect conflicts
- Server can reject saves if versions don't match
- Explicit conflict handling:
- When saving with outdated version
- When receiving server updates while local edits exist
- User agency:
- User is explicitly shown conflicts
- User chooses how to resolve differences
- Local changes are never silently discarded
This pattern can be further enhanced with:
- Operational transforms for smart merging
- Autosave with change tracking
- Undo/redo history that survives server updates
- Optimistic updates with rollback
Would you like me to elaborate on any of these aspects? Or shall we explore other scenarios where similar patterns might be useful?
Prompt:
An additional requirement: when changes arrive from another user, those are highlighted in the UI for a few seconds.
Response:
This is a good case for tracking not just the content but also the source and timing of changes. Here's how we can implement that with change highlighting:
function NoteEditor() {
// Server state with version tracking
const [savedNote, setSavedNote] = useState(initialNote);
// Local edits
const [localEdits, setLocalEdits] = useState(null);
// Track which parts of the content were recently changed by others
const [recentExternalChanges, setRecentExternalChanges] = useState(new Set());
// Helper to identify changes between versions
const findChangedSegments = (oldContent, newContent) => {
// This is a simplified diff - in practice you'd want a proper diff algorithm
const oldWords = oldContent.split(/\s+/);
const newWords = newContent.split(/\s+/);
const changes = new Set();
newWords.forEach((word, index) => {
if (word !== oldWords[index]) {
changes.add(index);
}
});
return changes;
};
// Handle incoming server updates
useEffect(() => {
const subscription = noteUpdates.subscribe(serverUpdate => {
if (!localEdits) {
// Identify what changed
const changedSegments = findChangedSegments(savedNote.content, serverUpdate.content);
setSavedNote(serverUpdate);
setRecentExternalChanges(changedSegments);
// Clear highlights after delay
setTimeout(() => {
setRecentExternalChanges(new Set());
}, 3000);
} else {
// ... conflict handling from previous example ...
}
});
return () => subscription.unsubscribe();
}, [savedNote, localEdits]);
// Split content into segments and render with highlights
const renderContent = (content) => {
const words = content.split(/(\s+)/);
return words.map((word, index) => (
<span
key={index}
className={recentExternalChanges.has(index) ? 'highlight-external-change' : ''}
style={{
backgroundColor: recentExternalChanges.has(index) ? '#ffeb3b' : 'transparent',
transition: 'background-color 0.5s ease'
}}
>
{word}
</span>
));
};
return (
<div>
<div
contentEditable
onInput={e => handleUserEdit(e.currentTarget.textContent)}
className="editor-content"
>
{renderContent(localEdits ?? savedNote.content)}
</div>
<button onClick={handleSave}>Save</button>
{localEdits && <div>Unsaved changes</div>}
</div>
);
}
// CSS
const styles = `
.highlight-external-change {
animation: fadeHighlight 3s;
}
@keyframes fadeHighlight {
0% { background-color: #ffeb3b; }
80% { background-color: #ffeb3b; }
100% { background-color: transparent; }
}
.editor-content {
white-space: pre-wrap;
border: 1px solid #ccc;
padding: 8px;
min-height: 100px;
}
`;
Key additions to handle change highlighting:
- Track recent changes:
-
recentExternalChanges
set stores which segments were changed - Changes are cleared after a timeout
-
- Content segmentation:
- Content is split into segments (words in this example)
- Each segment can be individually highlighted
- Visual feedback:
- CSS transitions for smooth highlight appearance/disappearance
- Highlights automatically fade after 3 seconds
For a more robust implementation, you might want to:
- Use a proper diff algorithm:
function betterDiff(oldContent, newContent) {
// Using a library like diff-match-patch or jsdiff
return diffLib.diffWords(oldContent, newContent).map(change => ({
value: change.value,
added: change.added,
removed: change.removed,
timestamp: Date.now()
}));
}
- Handle overlapping changes:
const ChangeTracker = () => {
const [changes, setChanges] = useState([]);
const addChange = (change) => {
setChanges(prev => [...prev, { ...change, id: uuid() }]);
// Remove change after delay
setTimeout(() => {
setChanges(prev => prev.filter(c => c.id !== change.id));
}, 3000);
};
return { changes, addChange };
};
- Add more sophisticated animations:
const ChangeHighlight = ({ change }) => (
<span
className={`
change-highlight
${change.type === 'external' ? 'external' : 'local'}
`}
style={{
'--highlight-start-time': change.timestamp
}}
>
{change.content}
</span>
);
This gives users clear visual feedback about what changed while maintaining a smooth editing experience. Would you like me to elaborate on any of these aspects or explore other related features?
Prompt:
Now write the same functionality using direct Dom.
Response:
Here's an implementation using direct DOM manipulation that handles collaborative editing with change highlighting:
Collaborative Editor with DOM Manipulation
// Track editor state
const editorState = {
content: '',
version: 0,
pendingChanges: null,
changeHighlights: new Map(), // tracks highlighted ranges
selectionState: null // track user selection during updates
};
class Editor {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.editor = document.createElement('div');
this.editor.contentEditable = true;
this.editor.className = 'editor';
this.container.appendChild(this.editor);
// Setup styles
const style = document.createElement('style');
style.textContent = `
.editor {
border: 1px solid #ccc;
padding: 8px;
min-height: 100px;
white-space: pre-wrap;
}
.external-change {
background-color: #ffeb3b;
transition: background-color 3s ease;
}
.external-change.fading {
background-color: transparent;
}
`;
document.head.appendChild(style);
this.setupEventListeners();
}
setupEventListeners() {
// Capture local changes
this.editor.addEventListener('input', () => {
// Store pending changes
editorState.pendingChanges = this.editor.innerHTML;
});
// Save selection state before any external updates
this.editor.addEventListener('keyup', () => {
this.saveSelection();
});
this.editor.addEventListener('mouseup', () => {
this.saveSelection();
});
}
saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
editorState.selectionState = selection.getRangeAt(0).cloneRange();
}
}
restoreSelection() {
if (editorState.selectionState) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(editorState.selectionState);
}
}
// Apply server updates while preserving local state
handleServerUpdate(serverContent, serverVersion) {
if (editorState.pendingChanges) {
// Have local changes, show conflict UI
this.showConflictUI(serverContent);
return;
}
// Find and highlight changes
const changes = this.findChanges(editorState.content, serverContent);
this.applyContentUpdate(serverContent, changes);
editorState.version = serverVersion;
}
findChanges(oldContent, newContent) {
// Simple word-based diff
const oldWords = oldContent.split(/(\s+|<[^>]+>)/);
const newWords = newContent.split(/(\s+|<[^>]+>)/);
const changes = [];
let pos = 0;
newWords.forEach((word, i) => {
if (word !== oldWords[i]) {
changes.push({
start: pos,
end: pos + word.length,
content: word
});
}
pos += word.length;
});
return changes;
}
applyContentUpdate(newContent, changes) {
// Save current selection
this.saveSelection();
// Update content
this.editor.innerHTML = newContent;
editorState.content = newContent;
// Apply highlights to changed regions
changes.forEach(change => {
this.highlightChange(change);
});
// Restore selection
this.restoreSelection();
}
highlightChange(change) {
const range = document.createRange();
const startNode = this.findNodeAtPosition(this.editor, change.start);
const endNode = this.findNodeAtPosition(this.editor, change.end);
if (startNode && endNode) {
range.setStart(startNode.node, startNode.offset);
range.setEnd(endNode.node, endNode.offset);
const highlight = document.createElement('span');
highlight.className = 'external-change';
range.surroundContents(highlight);
// Fade out after delay
setTimeout(() => {
highlight.classList.add('fading');
// Remove highlight span after animation
setTimeout(() => {
if (highlight.parentNode) {
highlight.replaceWith(...highlight.childNodes);
}
}, 3000);
}, 100);
}
}
findNodeAtPosition(root, targetPos) {
let currentPos = 0;
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
const nextPos = currentPos + node.length;
if (targetPos >= currentPos && targetPos <= nextPos) {
return {
node: node,
offset: targetPos - currentPos
};
}
currentPos = nextPos;
} else {
for (let child of node.childNodes) {
const result = walk(child);
if (result) return result;
}
}
return null;
}
return walk(root);
}
async save() {
if (!editorState.pendingChanges) return;
try {
const response = await fetch('/api/save-note', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: editorState.pendingChanges,
version: editorState.version
})
});
if (response.ok) {
const result = await response.json();
editorState.content = result.content;
editorState.version = result.version;
editorState.pendingChanges = null;
} else if (response.status === 409) {
// Conflict
const serverVersion = await response.json();
this.showConflictUI(serverVersion.content);
}
} catch (error) {
console.error('Save failed:', error);
}
}
showConflictUI(serverContent) {
const modal = document.createElement('div');
modal.className = 'conflict-modal';
modal.innerHTML = `
<div class="conflict-container">
<h3>Changes Conflict</h3>
<div class="versions">
<div class="local">
<h4>Your Version</h4>
<div class="content">${editorState.pendingChanges}</div>
</div>
<div class="server">
<h4>Server Version</h4>
<div class="content">${serverContent}</div>
</div>
</div>
<div class="actions">
<button class="keep-local">Keep Your Version</button>
<button class="keep-server">Accept Server Version</button>
<button class="merge">Merge Changes</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Handle conflict resolution
modal.querySelector('.keep-local').onclick = () => {
// Keep current editor content
modal.remove();
};
modal.querySelector('.keep-server').onclick = () => {
this.applyContentUpdate(serverContent, []);
editorState.pendingChanges = null;
modal.remove();
};
modal.querySelector('.merge').onclick = () => {
// Implement 3-way merge
// This would need a proper diff/merge algorithm
modal.remove();
};
}
}
// Usage:
const editor = new Editor('editor-container');
// Subscribe to server updates
const socket = new WebSocket('ws://your-server/updates');
socket.onmessage = (event) => {
const update = JSON.parse(event.data);
editor.handleServerUpdate(update.content, update.version);
};
Key differences from the React version:
- Direct DOM manipulation:
- No virtual DOM or reconciliation
- Manual selection preservation during updates
- Explicit DOM range manipulation for highlighting
- More precise control over timing:
- Direct access to DOM mutation events
- Immediate access to selection state
- Fine-grained control over highlight animations
- State management:
- Manual tracking of content versions
- Explicit handling of pending changes
- Direct mapping of highlights to DOM nodes
- Performance characteristics:
- Fewer abstractions/less overhead
- More direct control over DOM updates
- Potentially better performance for rapid updates
Some advantages of this approach:
- More precise control over DOM operations
- Better control over selection/cursor position
- Potentially smoother animations
- No framework overhead
Some challenges:
- More complex code
- Manual state management
- Cross-browser compatibility concerns
- More potential for memory leaks
Would you like me to elaborate on any particular aspect? Or shall we discuss specific scenarios where one approach might be preferable to the other?
Prompt:
The Dom code is more verbose, since we're not using helper library. But I would assert that it can support additional complexity of dynamic interactions without too much trouble. The react code, in contrast, is reaching the limits of legibility. The addition of more dynamic features will make the react code nearly impossible to reason about. This is primarily because it removes the ability to control precisely how the Dom is updated based on events.
Response:
I agree with your assessment. The direct DOM version has clearer causality - each piece of code directly expresses "when X happens, do Y to the DOM." While more verbose, the relationships between events and UI updates remain clear and traceable.
Let's consider adding a feature to illustrate this: showing which other users are currently editing which parts of the document, with their cursors and selections visible in real-time.
In the DOM version, we can simply add:
class Editor {
// ... existing code ...
addRemoteUser(userId, userName) {
const cursor = document.createElement('div');
cursor.className = `remote-cursor user-${userId}`;
cursor.innerHTML = `
<div class="cursor-flag">
${userName} is editing
</div>
`;
this.editor.appendChild(cursor);
return cursor;
}
updateRemoteSelection(userId, range) {
const cursor = this.remoteCursors.get(userId);
if (!cursor) return;
// Clear previous selection highlight
document.querySelectorAll(`.selection-${userId}`).forEach(el => {
el.replaceWith(...el.childNodes);
});
// Position cursor element at range end
const bounds = range.getBoundingClientRect();
cursor.style.transform = `translate(${bounds.right}px, ${bounds.top}px)`;
// Highlight selection
const highlight = document.createElement('span');
highlight.className = `remote-selection selection-${userId}`;
range.surroundContents(highlight);
}
handleRemoteUpdate(event) {
switch(event.type) {
case 'cursor-move':
this.updateRemoteSelection(event.userId, event.range);
break;
case 'content-change':
this.handleServerUpdate(event.content, event.version);
break;
case 'user-joined':
this.addRemoteUser(event.userId, event.userName);
break;
}
}
}
The React version would need something like:
function NoteEditor() {
// ... previous state ...
const [remoteCursors, setRemoteCursors] = useState(new Map());
const [remoteSelections, setRemoteSelections] = useState(new Map());
// Need to carefully manage when this effect runs
useEffect(() => {
// But wait - if we update cursor positions based on
// rendered content positions, we need to wait for render...
const updateCursorPositions = () => {
// And hope the DOM matches our understanding of the content
remoteCursors.forEach((cursor, userId) => {
const selection = remoteSelections.get(userId);
if (selection) {
// Try to map our content indices to actual DOM positions...
}
});
};
window.requestAnimationFrame(updateCursorPositions);
}, [content, remoteCursors, remoteSelections]);
// Break content into segments that preserve remote selections
const renderContent = () => {
// Somehow merge:
// - actual content
// - remote selections
// - local selections
// - recent change highlights
// All while maintaining correct cursor positions...
};
// Hope that React's reconciliation doesn't disrupt
// cursor positioning or selection rendering...
}
The React version becomes challenging because:
- We're fighting the abstraction - React wants to own the DOM update schedule, but we need precise control for cursor positions
- State interdependencies multiply - each new feature adds state that needs to be carefully synchronized with existing state
- The rendering logic becomes extremely complex as it tries to merge multiple concerns into a single state -> render transform
- Timing becomes implicit and hard to reason about - when exactly will cursor positions update relative to content changes?
The DOM version remains relatively clear because:
- Event handling is explicit
- State updates are synchronous and ordered
- DOM updates happen exactly when and how we specify
- New features mostly just add new event handlers and DOM manipulations, rather than complicated state interactions
This illustrates why direct DOM manipulation can be preferable for highly dynamic interfaces - the added verbosity is offset by better control and clearer causality.
Prompt:
Thank you. Your additional example was spot on. Do you have any further comments on my original assertion now?
Response:
Yes, I think your original point was more profound than I first appreciated. When you said "React reduces UI updates to State transitions" and that this is "inadequate for dynamic interfaces," the cursor/selection example really drives this home.
The fundamental issue is that React's mental model - where UI is a pure function of state - actually obscures the temporal and causal relationships that are essential in truly dynamic interfaces. The verbosity of DOM manipulation isn't just necessary overhead - it's actually expressing something important: the precise choreography of when and how UI elements should respond to events.
In the real-time collaboration example, the core questions are temporal and causal:
- When did this change happen?
- Who initiated it?
- How should it be visually introduced to other users?
- How do we maintain user focus and context through the update?
These aren't naturally expressed as state transitions. Trying to force them into React's model requires increasingly complex state management that actually makes the code's intent less clear, not more clear.
I initially approached this as a state management problem that could be solved with clever React patterns. But your examples showed that the real issue is more fundamental - it's about the ability to express and reason about causality and temporal relationships in the UI. Direct DOM manipulation makes these relationships explicit and controllable, while React's abstraction can hide them.
Top comments (0)