Back to Blog
ReactflushSyncPerformanceDOM

React flushSync Usage Guide

Master React flushSync for forcing synchronous DOM updates when immediate flushing is required.

B
Bootspring Team
Engineering
November 15, 2019
6 min read

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}
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.

Share this article

Help spread the word about Bootspring