Back to Blog
ReactuseTransitionHooksPerformance

React useTransition Hook Guide

Master the React useTransition hook for managing UI transitions and keeping interfaces responsive during updates.

B
Bootspring Team
Engineering
March 24, 2019
6 min read

The useTransition hook lets you mark state updates as non-urgent, keeping your UI responsive during expensive renders. Here's how to use it.

Basic Usage#

1import { useState, useTransition } from 'react'; 2 3function SearchResults() { 4 const [query, setQuery] = useState(''); 5 const [results, setResults] = useState([]); 6 const [isPending, startTransition] = useTransition(); 7 8 const handleChange = (e) => { 9 const value = e.target.value; 10 11 // Urgent: Update input immediately 12 setQuery(value); 13 14 // Non-urgent: Update results can wait 15 startTransition(() => { 16 setResults(filterResults(value)); 17 }); 18 }; 19 20 return ( 21 <div> 22 <input value={query} onChange={handleChange} /> 23 {isPending && <div>Loading...</div>} 24 <ResultsList results={results} /> 25 </div> 26 ); 27}

Tab Switching#

1import { useState, useTransition, memo } from 'react'; 2 3const SlowTab = memo(function SlowTab() { 4 // Simulate slow render 5 let items = []; 6 for (let i = 0; i < 500; i++) { 7 items.push(<SlowItem key={i} index={i} />); 8 } 9 return <div>{items}</div>; 10}); 11 12function TabContainer() { 13 const [tab, setTab] = useState('about'); 14 const [isPending, startTransition] = useTransition(); 15 16 function selectTab(nextTab) { 17 startTransition(() => { 18 setTab(nextTab); 19 }); 20 } 21 22 return ( 23 <div> 24 <nav> 25 <button 26 className={tab === 'about' ? 'active' : ''} 27 onClick={() => selectTab('about')} 28 > 29 About 30 </button> 31 <button 32 className={tab === 'posts' ? 'active' : ''} 33 onClick={() => selectTab('posts')} 34 > 35 Posts {isPending && '...'} 36 </button> 37 <button 38 className={tab === 'contact' ? 'active' : ''} 39 onClick={() => selectTab('contact')} 40 > 41 Contact 42 </button> 43 </nav> 44 45 <div className={isPending ? 'pending' : ''}> 46 {tab === 'about' && <AboutTab />} 47 {tab === 'posts' && <SlowTab />} 48 {tab === 'contact' && <ContactTab />} 49 </div> 50 </div> 51 ); 52}

List Filtering#

1import { useState, useTransition, useMemo } from 'react'; 2 3function FilterableList({ items }) { 4 const [filter, setFilter] = useState(''); 5 const [displayFilter, setDisplayFilter] = useState(''); 6 const [isPending, startTransition] = useTransition(); 7 8 const filteredItems = useMemo(() => { 9 return items.filter((item) => 10 item.name.toLowerCase().includes(displayFilter.toLowerCase()) 11 ); 12 }, [items, displayFilter]); 13 14 const handleFilterChange = (e) => { 15 const value = e.target.value; 16 17 // Update input immediately 18 setFilter(value); 19 20 // Defer the actual filtering 21 startTransition(() => { 22 setDisplayFilter(value); 23 }); 24 }; 25 26 return ( 27 <div> 28 <input 29 type="text" 30 value={filter} 31 onChange={handleFilterChange} 32 placeholder="Filter items..." 33 /> 34 35 <div className={isPending ? 'list pending' : 'list'}> 36 {filteredItems.map((item) => ( 37 <ListItem key={item.id} item={item} /> 38 ))} 39 </div> 40 </div> 41 ); 42}
1import { useState, useTransition } from 'react'; 2 3function Router() { 4 const [page, setPage] = useState('home'); 5 const [isPending, startTransition] = useTransition(); 6 7 const navigate = (newPage) => { 8 startTransition(() => { 9 setPage(newPage); 10 }); 11 }; 12 13 return ( 14 <div> 15 <nav> 16 <NavLink 17 to="home" 18 active={page === 'home'} 19 pending={isPending} 20 onClick={() => navigate('home')} 21 /> 22 <NavLink 23 to="dashboard" 24 active={page === 'dashboard'} 25 pending={isPending} 26 onClick={() => navigate('dashboard')} 27 /> 28 <NavLink 29 to="settings" 30 active={page === 'settings'} 31 pending={isPending} 32 onClick={() => navigate('settings')} 33 /> 34 </nav> 35 36 {isPending && <LoadingBar />} 37 38 <main style={{ opacity: isPending ? 0.7 : 1 }}> 39 <PageContent page={page} /> 40 </main> 41 </div> 42 ); 43} 44 45function NavLink({ to, active, pending, onClick, children }) { 46 return ( 47 <button 48 className={`nav-link ${active ? 'active' : ''} ${pending ? 'pending' : ''}`} 49 onClick={onClick} 50 > 51 {children || to} 52 </button> 53 ); 54}

Form with Expensive Validation#

1import { useState, useTransition } from 'react'; 2 3function ComplexForm() { 4 const [formData, setFormData] = useState({ 5 username: '', 6 email: '', 7 bio: '', 8 }); 9 const [validationResult, setValidationResult] = useState(null); 10 const [isPending, startTransition] = useTransition(); 11 12 const handleChange = (field) => (e) => { 13 const value = e.target.value; 14 15 // Update form immediately 16 setFormData((prev) => ({ ...prev, [field]: value })); 17 18 // Defer expensive validation 19 startTransition(() => { 20 const result = validateForm({ ...formData, [field]: value }); 21 setValidationResult(result); 22 }); 23 }; 24 25 return ( 26 <form> 27 <div> 28 <input 29 value={formData.username} 30 onChange={handleChange('username')} 31 placeholder="Username" 32 /> 33 </div> 34 35 <div> 36 <input 37 value={formData.email} 38 onChange={handleChange('email')} 39 placeholder="Email" 40 /> 41 </div> 42 43 <div> 44 <textarea 45 value={formData.bio} 46 onChange={handleChange('bio')} 47 placeholder="Bio" 48 /> 49 </div> 50 51 {isPending ? ( 52 <span>Validating...</span> 53 ) : validationResult?.errors ? ( 54 <ul className="errors"> 55 {validationResult.errors.map((err, i) => ( 56 <li key={i}>{err}</li> 57 ))} 58 </ul> 59 ) : null} 60 </form> 61 ); 62}

Search with Debounce Alternative#

1import { useState, useTransition, useDeferredValue } from 'react'; 2 3// Using useTransition 4function SearchWithTransition() { 5 const [query, setQuery] = useState(''); 6 const [searchResults, setSearchResults] = useState([]); 7 const [isPending, startTransition] = useTransition(); 8 9 const handleSearch = (e) => { 10 const value = e.target.value; 11 setQuery(value); 12 13 startTransition(() => { 14 // Expensive search operation 15 const results = performSearch(value); 16 setSearchResults(results); 17 }); 18 }; 19 20 return ( 21 <div> 22 <input value={query} onChange={handleSearch} /> 23 {isPending && <Spinner />} 24 <SearchResults results={searchResults} /> 25 </div> 26 ); 27} 28 29// Alternative: useDeferredValue 30function SearchWithDeferredValue() { 31 const [query, setQuery] = useState(''); 32 const deferredQuery = useDeferredValue(query); 33 const isStale = query !== deferredQuery; 34 35 const searchResults = useMemo( 36 () => performSearch(deferredQuery), 37 [deferredQuery] 38 ); 39 40 return ( 41 <div> 42 <input value={query} onChange={(e) => setQuery(e.target.value)} /> 43 <div style={{ opacity: isStale ? 0.5 : 1 }}> 44 <SearchResults results={searchResults} /> 45 </div> 46 </div> 47 ); 48}

Multiple Transitions#

1import { useState, useTransition } from 'react'; 2 3function Dashboard() { 4 const [data, setData] = useState(null); 5 const [filter, setFilter] = useState('all'); 6 const [sort, setSort] = useState('date'); 7 8 const [isFilterPending, startFilterTransition] = useTransition(); 9 const [isSortPending, startSortTransition] = useTransition(); 10 11 const handleFilterChange = (newFilter) => { 12 startFilterTransition(() => { 13 setFilter(newFilter); 14 }); 15 }; 16 17 const handleSortChange = (newSort) => { 18 startSortTransition(() => { 19 setSort(newSort); 20 }); 21 }; 22 23 const processedData = useMemo(() => { 24 if (!data) return []; 25 let result = [...data]; 26 27 if (filter !== 'all') { 28 result = result.filter((item) => item.category === filter); 29 } 30 31 result.sort((a, b) => { 32 if (sort === 'date') return b.date - a.date; 33 if (sort === 'name') return a.name.localeCompare(b.name); 34 return 0; 35 }); 36 37 return result; 38 }, [data, filter, sort]); 39 40 return ( 41 <div> 42 <FilterBar 43 filter={filter} 44 onChange={handleFilterChange} 45 isPending={isFilterPending} 46 /> 47 <SortBar 48 sort={sort} 49 onChange={handleSortChange} 50 isPending={isSortPending} 51 /> 52 <DataTable data={processedData} /> 53 </div> 54 ); 55}

With Suspense#

1import { useState, useTransition, Suspense } from 'react'; 2 3function App() { 4 const [tab, setTab] = useState('home'); 5 const [isPending, startTransition] = useTransition(); 6 7 return ( 8 <div> 9 <TabButtons 10 activeTab={tab} 11 isPending={isPending} 12 onSelect={(nextTab) => { 13 startTransition(() => { 14 setTab(nextTab); 15 }); 16 }} 17 /> 18 19 <Suspense fallback={<Spinner />}> 20 <TabPanel tab={tab} /> 21 </Suspense> 22 </div> 23 ); 24} 25 26// Lazy loaded components work seamlessly 27const TabPanel = ({ tab }) => { 28 switch (tab) { 29 case 'home': 30 return <HomePage />; 31 case 'profile': 32 return <ProfilePage />; // May suspend while loading 33 case 'settings': 34 return <SettingsPage />; 35 default: 36 return null; 37 } 38};

Best Practices#

When to Use: ✓ Tab switching with heavy content ✓ Search/filter with large lists ✓ Form validation feedback ✓ Navigation between views ✓ Any non-urgent state update Patterns: ✓ Pair urgent and non-urgent updates ✓ Show isPending feedback ✓ Use with Suspense for data fetching ✓ Consider useDeferredValue alternative Performance: ✓ Wrap only the slow update ✓ Keep transitions granular ✓ Don't nest startTransition calls ✓ Use memo for expensive children Avoid: ✗ Wrapping all state updates ✗ Using for urgent UI feedback ✗ Controlled input value in transition ✗ Ignoring isPending state

Conclusion#

The useTransition hook keeps your UI responsive by marking state updates as non-urgent. Use it for tab switching, list filtering, and navigation where the update can be deferred. The isPending flag lets you show loading states while transitions process. Pair it with useMemo for expensive computations and Suspense for data fetching. Remember: urgent updates like text input should stay outside startTransition.

Share this article

Help spread the word about Bootspring