The flushSync function forces React to flush pending updates synchronously. Here's when and how to use it.
Basic Usage#
1import { flushSync } from 'react-dom';
2
3function SearchInput() {
4 const [query, setQuery] = useState('');
5 const inputRef = useRef<HTMLInputElement>(null);
6
7 const handleSearch = () => {
8 // Force synchronous update
9 flushSync(() => {
10 setQuery('');
11 });
12
13 // DOM is now updated
14 inputRef.current?.focus();
15 };
16
17 return (
18 <div>
19 <input
20 ref={inputRef}
21 value={query}
22 onChange={(e) => setQuery(e.target.value)}
23 />
24 <button onClick={handleSearch}>Clear & Focus</button>
25 </div>
26 );
27}Scroll Position Management#
1import { flushSync } from 'react-dom';
2
3function ChatMessages({ messages, onSendMessage }) {
4 const listRef = useRef<HTMLDivElement>(null);
5 const [newMessage, setNewMessage] = useState('');
6
7 const handleSend = () => {
8 // Add message synchronously
9 flushSync(() => {
10 onSendMessage(newMessage);
11 setNewMessage('');
12 });
13
14 // Scroll to bottom after DOM update
15 listRef.current?.scrollTo({
16 top: listRef.current.scrollHeight,
17 behavior: 'smooth',
18 });
19 };
20
21 return (
22 <div>
23 <div ref={listRef} className="messages">
24 {messages.map((msg) => (
25 <div key={msg.id}>{msg.text}</div>
26 ))}
27 </div>
28 <input
29 value={newMessage}
30 onChange={(e) => setNewMessage(e.target.value)}
31 />
32 <button onClick={handleSend}>Send</button>
33 </div>
34 );
35}DOM Measurements#
1import { flushSync } from 'react-dom';
2
3function ExpandingTextarea() {
4 const [value, setValue] = useState('');
5 const textareaRef = useRef<HTMLTextAreaElement>(null);
6
7 const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
8 const newValue = e.target.value;
9
10 // Update value synchronously
11 flushSync(() => {
12 setValue(newValue);
13 });
14
15 // Measure and adjust height
16 if (textareaRef.current) {
17 textareaRef.current.style.height = 'auto';
18 textareaRef.current.style.height =
19 textareaRef.current.scrollHeight + 'px';
20 }
21 };
22
23 return (
24 <textarea
25 ref={textareaRef}
26 value={value}
27 onChange={handleChange}
28 style={{ resize: 'none', overflow: 'hidden' }}
29 />
30 );
31}Animation Coordination#
1import { flushSync } from 'react-dom';
2
3function AnimatedList({ items, onRemove }) {
4 const handleRemove = (id: string) => {
5 const element = document.getElementById(`item-${id}`);
6
7 if (element) {
8 // Start exit animation
9 element.classList.add('fade-out');
10
11 // Wait for animation
12 element.addEventListener('animationend', () => {
13 // Update state synchronously after animation
14 flushSync(() => {
15 onRemove(id);
16 });
17 }, { once: true });
18 }
19 };
20
21 return (
22 <ul>
23 {items.map((item) => (
24 <li key={item.id} id={`item-${item.id}`}>
25 {item.name}
26 <button onClick={() => handleRemove(item.id)}>Remove</button>
27 </li>
28 ))}
29 </ul>
30 );
31}Focus Management#
1import { flushSync } from 'react-dom';
2
3function EditableList({ items, onUpdate }) {
4 const [editingId, setEditingId] = useState<string | null>(null);
5 const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map());
6
7 const startEditing = (id: string) => {
8 flushSync(() => {
9 setEditingId(id);
10 });
11
12 // Focus the input after it renders
13 inputRefs.current.get(id)?.focus();
14 };
15
16 const stopEditing = () => {
17 setEditingId(null);
18 };
19
20 return (
21 <ul>
22 {items.map((item) => (
23 <li key={item.id}>
24 {editingId === item.id ? (
25 <input
26 ref={(el) => {
27 if (el) inputRefs.current.set(item.id, el);
28 }}
29 defaultValue={item.name}
30 onBlur={stopEditing}
31 onKeyDown={(e) => e.key === 'Enter' && stopEditing()}
32 />
33 ) : (
34 <span onClick={() => startEditing(item.id)}>
35 {item.name}
36 </span>
37 )}
38 </li>
39 ))}
40 </ul>
41 );
42}Third-Party Library Integration#
1import { flushSync } from 'react-dom';
2import { useEffect, useRef, useState } from 'react';
3
4function ChartWithControls({ data }) {
5 const [selectedRange, setSelectedRange] = useState([0, 100]);
6 const chartRef = useRef<Chart | null>(null);
7 const containerRef = useRef<HTMLDivElement>(null);
8
9 useEffect(() => {
10 // Initialize chart library
11 if (containerRef.current) {
12 chartRef.current = new Chart(containerRef.current, {
13 data: filterData(data, selectedRange),
14 });
15 }
16
17 return () => chartRef.current?.destroy();
18 }, []);
19
20 const handleRangeChange = (newRange: [number, number]) => {
21 // Update state synchronously
22 flushSync(() => {
23 setSelectedRange(newRange);
24 });
25
26 // Update chart library immediately
27 chartRef.current?.update({
28 data: filterData(data, newRange),
29 });
30 };
31
32 return (
33 <div>
34 <RangeSlider value={selectedRange} onChange={handleRangeChange} />
35 <div ref={containerRef} />
36 </div>
37 );
38}Form Validation#
1import { flushSync } from 'react-dom';
2
3function ValidatedForm() {
4 const [values, setValues] = useState({ name: '', email: '' });
5 const [errors, setErrors] = useState<Record<string, string>>({});
6 const errorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
7
8 const validate = () => {
9 const newErrors: Record<string, string> = {};
10
11 if (!values.name) newErrors.name = 'Name is required';
12 if (!values.email.includes('@')) newErrors.email = 'Invalid email';
13
14 return newErrors;
15 };
16
17 const handleSubmit = (e: React.FormEvent) => {
18 e.preventDefault();
19
20 const validationErrors = validate();
21
22 if (Object.keys(validationErrors).length > 0) {
23 // Update errors synchronously
24 flushSync(() => {
25 setErrors(validationErrors);
26 });
27
28 // Scroll to first error
29 const firstErrorKey = Object.keys(validationErrors)[0];
30 errorRefs.current.get(firstErrorKey)?.scrollIntoView({
31 behavior: 'smooth',
32 block: 'center',
33 });
34
35 return;
36 }
37
38 // Submit form
39 submitForm(values);
40 };
41
42 return (
43 <form onSubmit={handleSubmit}>
44 <div ref={(el) => el && errorRefs.current.set('name', el)}>
45 <input
46 value={values.name}
47 onChange={(e) => setValues({ ...values, name: e.target.value })}
48 />
49 {errors.name && <span className="error">{errors.name}</span>}
50 </div>
51
52 <div ref={(el) => el && errorRefs.current.set('email', el)}>
53 <input
54 value={values.email}
55 onChange={(e) => setValues({ ...values, email: e.target.value })}
56 />
57 {errors.email && <span className="error">{errors.email}</span>}
58 </div>
59
60 <button type="submit">Submit</button>
61 </form>
62 );
63}Modal Close with Cleanup#
1import { flushSync } from 'react-dom';
2
3function ModalWithAnimation({ isOpen, onClose, children }) {
4 const [isAnimating, setIsAnimating] = useState(false);
5 const modalRef = useRef<HTMLDivElement>(null);
6
7 const handleClose = () => {
8 setIsAnimating(true);
9
10 // Wait for exit animation
11 setTimeout(() => {
12 // Synchronously update before callback
13 flushSync(() => {
14 setIsAnimating(false);
15 });
16
17 // Parent's onClose runs after DOM update
18 onClose();
19 }, 300);
20 };
21
22 if (!isOpen && !isAnimating) return null;
23
24 return (
25 <div
26 ref={modalRef}
27 className={`modal ${isAnimating ? 'fade-out' : 'fade-in'}`}
28 >
29 <div className="modal-content">
30 {children}
31 <button onClick={handleClose}>Close</button>
32 </div>
33 </div>
34 );
35}Batching Override#
1import { flushSync } from 'react-dom';
2
3function Counter() {
4 const [count, setCount] = useState(0);
5 const [log, setLog] = useState<string[]>([]);
6
7 const handleClick = () => {
8 // Normally these would batch
9 setCount((c) => c + 1);
10 setCount((c) => c + 1);
11 setCount((c) => c + 1);
12 // One render with count = 3
13
14 // With flushSync, each triggers a render
15 flushSync(() => setCount((c) => c + 1));
16 // Render 1: count = 1
17 flushSync(() => setCount((c) => c + 1));
18 // Render 2: count = 2
19 flushSync(() => setCount((c) => c + 1));
20 // Render 3: count = 3
21 };
22
23 return (
24 <div>
25 <p>Count: {count}</p>
26 <button onClick={handleClick}>Increment 3x</button>
27 </div>
28 );
29}Best Practices#
When to Use:
✓ DOM measurements after update
✓ Scroll position management
✓ Focus after render
✓ Third-party library sync
Performance:
✗ Avoid in hot paths
✗ Don't use for every update
✗ Prefer React's batching
✗ Consider alternatives first
Alternatives:
✓ useLayoutEffect for measurements
✓ useEffect for most side effects
✓ ref callbacks for elements
✓ State lifting for coordination
Avoid:
✗ Inside render functions
✗ For animation frames
✗ In tight loops
✗ When batching works
Conclusion#
The flushSync function forces synchronous DOM updates when you need immediate access to updated DOM. Use it sparingly for focus management, scroll positioning, and third-party library integration. In most cases, React's automatic batching and useLayoutEffect provide better alternatives with less performance impact.