Mark Non-Critical Updates in React 19
In React 19, not all state updates are equally important. Critical updates (like responding to user input) must complete immediately, while non-critical updates (like updating a sidebar sidebar or refreshing analytics) can wait. Marking updates correctly is the foundation of a responsive application. This article teaches you how to identify which updates are critical, how to mark non-critical ones, and the architectural patterns that emerge from proper priority thinking.
The principle is simple: critical updates = high priority, non-critical updates = transitions. Critical updates include direct user interactions (typing, clicking, submitting). Non-critical updates include derived state, analytics, heavy filtering, or background tasks. A well-structured React component explicitly marks non-critical work.
Critical vs. Non-Critical Updates: Decision Matrix
| Update Type | Marks As | Why | Example |
|---|---|---|---|
| Input field change | High priority | User expects instant feedback | typing in a search box |
| Button click with immediate feedback | High priority | Confirms user action was received | toggling a checkbox |
| Filtering/searching a list | Transition | Can be interrupted, slow render | filter 100k items |
| API response data | Depends | If the response is critical, high priority; if background sync, transition | loading a user profile vs. refreshing suggestions |
| Analytics/logging | Transition | Shouldn't block UI | tracking a user event |
| Updating a sidebar preview | Transition | User doesn't expect immediate update | preview of selected item |
| Sorting a large dataset | Transition | Expensive, can be interrupted | sorting by date |
| Pagination (loading next page) | Transition | Network-bound, UI shows old page while loading | loading more results |
The key insight: Did the user directly cause this update? If yes, high priority. If it's a consequence or a background task, transition.
Pattern 1: Separating Input from Computation
The most common pattern is keeping the input synchronized (high priority) while deferring expensive computation (transition):
import { useState, useTransition } from 'react';
export function SmartFormHandler() {
// Critical state: what the user typed
const [formData, setFormData] = useState({
email: '',
password: '',
username: '',
});
// Non-critical state: validation results and suggestions
const [validation, setValidation] = useState({});
const [suggestions, setSuggestions] = useState([]);
const [isPending, startTransition] = useTransition();
const handleInputChange = (e) => {
const { name, value } = e.target;
// High priority: update the form immediately
setFormData(prev => ({
...prev,
[name]: value,
}));
// Low priority: compute validation and suggestions
startTransition(() => {
const validationResult = validateEmail(value);
setValidation(prev => ({
...prev,
[name]: validationResult,
}));
if (name === 'username') {
const usernamesuggestions = generateUsernameSuggestions(value);
setSuggestions(usernamesuggestions);
}
});
};
return (
<form>
<label>
Email:
<input
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="[email protected]"
/>
</label>
{isPending ? (
<p style={{ fontSize: '12px', color: '#999' }}>Validating...</p>
) : (
validation.email && <p style={{ color: 'red' }}>{validation.email}</p>
)}
<label>
Username:
<input
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder="Pick a username"
/>
</label>
{suggestions.length > 0 && (
<ul style={{ fontSize: '12px' }}>
{suggestions.map(s => <li key={s}>{s}</li>)}
</ul>
)}
<label>
Password:
<input
name="password"
type="password"
value={formData.password}
onChange={handleInputChange}
placeholder="••••••••"
/>
</label>
<button type="submit">Sign Up</button>
</form>
);
}
function validateEmail(email) {
// Expensive: regex, server check, etc.
if (!email.includes('@')) return 'Invalid email';
return '';
}
function generateUsernameSuggestions(partial) {
// Expensive: database query, complex generation
const suggestions = Array.from({ length: 5 }, (_, i) => `${partial}${i}`);
return suggestions;
}
Here, the form feels snappy because input updates are high priority. Validation and suggestions appear with a slight delay, which is acceptable.
Pattern 2: Analytics and Logging as Transitions
Tracking user actions should never block the main thread. Mark analytics calls as transitions:
import { useTransition } from 'react';
export function AnalyticsButton() {
const [clicked, setClicked] = useState(false);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
// High priority: immediate visual feedback
setClicked(true);
// Low priority: log the event
startTransition(() => {
logEvent('button_clicked', {
timestamp: new Date().toISOString(),
userId: getCurrentUserId(),
});
});
};
return (
<button onClick={handleClick} disabled={isPending}>
{clicked ? 'Clicked!' : 'Click me'}
</button>
);
}
function logEvent(eventName, data) {
// Expensive: serialize, compress, send to server
fetch('/api/events', {
method: 'POST',
body: JSON.stringify({ eventName, data }),
}).catch(() => {
// Fail silently—analytics shouldn't crash the app
});
}
The button responds instantly. The analytics call happens as a transition, so if the network is slow, it won't freeze the UI.
Pattern 3: Real-Time Previews with Deferred Values
When a parent component rapidly changes a value (e.g., a list of files being selected), the child can defer rendering the preview:
import { useDeferredValue, useMemo } from 'react';
export function FileManager() {
const [selectedFileIds, setSelectedFileIds] = useState([]);
return (
<div>
<FileList selectedIds={selectedFileIds} onSelect={setSelectedFileIds} />
<FilePreview selectedIds={selectedFileIds} />
</div>
);
}
function FilePreview({ selectedIds }) {
// Defer rendering the preview until there's idle time
const deferredSelectedIds = useDeferredValue(selectedIds);
const previewData = useMemo(() => {
// Expensive: load and render previews for all selected files
return loadFilePreviews(deferredSelectedIds);
}, [deferredSelectedIds]);
return (
<div>
{deferredSelectedIds.length > selectedIds.length && (
<p style={{ fontSize: '12px', color: '#999' }}>Loading preview...</p>
)}
<div>
{previewData.map(preview => (
<div key={preview.id} style={{ marginBottom: '20px' }}>
<h3>{preview.name}</h3>
<img src={preview.thumbnailUrl} alt={preview.name} />
</div>
))}
</div>
</div>
);
}
function loadFilePreviews(fileIds) {
// Expensive: fetch metadata, generate thumbnails
return fileIds.map(id => ({
id,
name: `File ${id}`,
thumbnailUrl: `/thumbnails/${id}.jpg`,
}));
}
function FileList({ selectedIds, onSelect }) {
const files = generateFiles(1000);
const handleSelectAll = () => {
onSelect(files.map(f => f.id));
};
return (
<div>
<button onClick={handleSelectAll}>Select All</button>
<p>Selected: {selectedIds.length} files</p>
</div>
);
}
function generateFiles(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
name: `File ${i}`,
}));
}
The file selection updates immediately; the preview defers and updates once the browser is ready.
Anti-Patterns to Avoid
Anti-pattern 1: Marking everything as a transition
// DON'T do this
const [count, setCount] = useState(0);
const handleIncrement = () => {
startTransition(() => {
setCount(c => c + 1); // Don't mark simple state updates as transitions
});
};
A simple counter should update immediately, not as a transition.
Anti-pattern 2: Mixing critical and non-critical updates in one startTransition
// DON'T do this
const handleSubmit = () => {
startTransition(() => {
setFormData(data); // Critical—update immediately
sendToServer(data); // Non-critical—can be deferred
});
};
// DO this instead
const handleSubmit = () => {
setFormData(data); // Critical: high priority
startTransition(() => {
sendToServer(data); // Non-critical: transition
});
};
Anti-pattern 3: Deferring user-facing updates
// DON'T do this
const handleCheckbox = (e) => {
startTransition(() => {
setIsChecked(e.target.checked); // User expects instant checkbox toggle
});
};
// DO this instead
const handleCheckbox = (e) => {
setIsChecked(e.target.checked); // High priority
startTransition(() => {
saveCheckboxState(e.target.checked); // Non-critical
});
};
Key Takeaways
- Mark updates as transitions only when they're non-critical (filters, suggestions, analytics, background syncs).
- Separate the input update (high priority) from the computation (transition) in the same event handler.
- Use
isPendingoruseDeferredValuemismatches to show optional feedback. - Analytics, logging, and background tasks should always be transitions.
- Never mark direct user interactions (typing, clicking, submitting) as transitions unless there's a good reason.
Frequently Asked Questions
What happens if I mark a critical update as a transition?
The update can be interrupted, so if a higher-priority task arrives, the critical update is abandoned and restarted. This can cause the UI to feel laggy or unresponsive.
Can I have multiple non-critical updates in one handler?
Yes. You can call startTransition multiple times, or wrap multiple state updates in a single startTransition. React will batch them and defer all of them.
Is it okay to mark API calls as transitions?
Yes, but only the state update from the API response. The fetch itself should happen outside startTransition. Once the response arrives, update state inside startTransition.
How do I know if an update is taking too long?
Use React DevTools Profiler. Record a session, find your component, and check how long each render takes. If it's >50 ms, mark it as a transition.