Skip to main content

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 TypeMarks AsWhyExample
Input field changeHigh priorityUser expects instant feedbacktyping in a search box
Button click with immediate feedbackHigh priorityConfirms user action was receivedtoggling a checkbox
Filtering/searching a listTransitionCan be interrupted, slow renderfilter 100k items
API response dataDependsIf the response is critical, high priority; if background sync, transitionloading a user profile vs. refreshing suggestions
Analytics/loggingTransitionShouldn't block UItracking a user event
Updating a sidebar previewTransitionUser doesn't expect immediate updatepreview of selected item
Sorting a large datasetTransitionExpensive, can be interruptedsorting by date
Pagination (loading next page)TransitionNetwork-bound, UI shows old page while loadingloading 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 isPending or useDeferredValue mismatches 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.

Further Reading