Skip to main content

useFacetTransition

React Facet provides APIs for integrating facet updates with React 18's concurrent rendering features. These allow you to mark facet updates as non-urgent transitions, enabling React to keep the UI responsive during heavy updates.

The Hook: useFacetTransition​

A hook that works analogously to React's useTransition, but ensures that any React state changes resulting from facet updates are handled within a React transition.

Hook Signature​

typescript
function useFacetTransition(): [boolean, (fn: () => void) => void]

Returns: A tuple containing:

  1. isPending - Boolean indicating if a transition is in progress
  2. startTransition - Function to execute facet updates as a transition
Callback Stability

The startTransition function returned by useFacetTransition is stable across re-renders and doesn't need to be included in dependency arrays of useCallback, useEffect, or other hooks.

When to Use​

Use useFacetTransition when:

  1. Heavy facet updates - Facet changes trigger expensive computations or rendering
  2. Keeping UI responsive - Need to prioritize user interactions over state updates
  3. Large lists or complex UIs - Updates affect many components simultaneously
  4. Mixed React state and facets - Some components use React state alongside facets

Basic Usage​

The following example keeps input fields responsive while processing updates, during which time it indicates to the user that it is loading via the isPending flag:

tsx
import { useFacetState, useFacetTransition } from '@react-facet/core'
 
const SearchResults = () => {
const [queryFacet, setQuery] = useFacetState('')
const [resultsFacet, setResults] = useFacetState<string[]>([])
const [isPending, startTransition] = useFacetTransition()
 
const handleSearch = (newQuery: string) => {
// Update query immediately (high priority)
setQuery(newQuery)
 
// Update results as a transition (low priority)
startTransition(() => {
// Expensive computation or data fetching
const results = performExpensiveSearch(newQuery)
setResults(results)
})
}
 
return (
<div>
<input type="text" onChange={(e) => handleSearch(e.target.value)} placeholder="Search..." />
{isPending && <div>Searching...</div>}
<fast-div style={{ opacity: isPending ? 0.5 : 1 }}>{/* Results display */}</fast-div>
</div>
)
}
 
const performExpensiveSearch = (query: string): string[] => {
// Simulate expensive search
return Array.from({ length: 1000 }, (_, i) => `Result ${i} for ${query}`)
}

The Function: startFacetTransition​

A function API that works analogously to React's startTransition, for use outside of components.

Function Signature​

typescript
function startFacetTransition(fn: () => void): void

Parameters:

  • fn - Function containing facet updates to execute as a transition

When to Use startFacetTransition​

Use startFacetTransition when:

  1. Outside React components - In utility functions, event handlers, or callbacks
  2. Global state updates - Updating shared facets from non-React code
  3. One-off transitions - Don't need the isPending state
  4. Event handlers - Processing events that trigger heavy facet updates

Usage Examples​

tsx
import { useFacetState, startFacetTransition } from '@react-facet/core'
 
// Utility function outside of React
export const loadDataAsTransition = (setData: (data: string[]) => void, newData: string[]) => {
startFacetTransition(() => {
// Heavy update marked as low priority
setData(newData)
})
}
 
// Use in component
const Component = () => {
const [dataFacet, setData] = useFacetState<string[]>([])
 
const handleLoad = () => {
const data = Array.from({ length: 5000 }, (_, i) => `Item ${i}`)
loadDataAsTransition(setData, data)
}
 
return <button onClick={handleLoad}>Load Data</button>
}

In Event Handlers​

tsx
import { useFacetState, startFacetTransition } from '@react-facet/core'
 
const BatchProcessor = () => {
const [statusFacet, setStatus] = useFacetState('Ready')
 
const processBatch = (items: string[]) => {
setStatus('Processing...')
 
// Process as transition - won't block UI
startFacetTransition(() => {
items.forEach((item) => {
// Heavy processing per item
processItem(item)
})
setStatus('Complete')
})
}
 
return (
<div>
<button onClick={() => processBatch(generateItems())}>Process Batch</button>
<fast-text text={statusFacet} />
</div>
)
}
 
const generateItems = () => Array.from({ length: 1000 }, (_, i) => `Item ${i}`)
const processItem = (item: string) => {
/* heavy processing */
}

With Shared State​

When working with shared state across multiple components, startFacetTransition (the function API) is often preferable to useFacetTransition (the hook):

Why use startFacetTransition for shared state:

  • No pending state needed - Notifications are "fire-and-forget", consumers don't need loading indicators
  • Called from children - The transition is triggered in child components, not where isPending would be available
  • Cleaner API - No need to expose isPending through context if it won't be used
  • Better performance - Provider doesn't re-render when isPending changes

Comparison:

tsx
import { useFacetState, useFacetTransition, startFacetTransition, NO_VALUE } from '@react-facet/core'
import { createContext, useContext } from 'react'
import type { Facet } from '@react-facet/core'
 
// ❌ Less ideal: Using useFacetTransition in provider causes re-renders
type NotificationContextBad = {
notificationsFacet: Facet<string[]>
addNotification: (message: string) => void
isPending: boolean // Provider re-renders when this changes
}
 
const NotificationContextBad = createContext<NotificationContextBad | null>(null)
 
export const NotificationProviderBad = ({ children }: { children: React.ReactNode }) => {
const [notificationsFacet, setNotifications] = useFacetState<string[]>([])
const [isPending, startTransition] = useFacetTransition()
 
const addNotification = (message: string) => {
startTransition(() => {
setNotifications((current) => (current !== NO_VALUE ? [...current, message] : [message]))
})
}
 
// ⚠️ Provider re-renders on every isPending change, even if not used
return (
<NotificationContextBad.Provider value={{ notificationsFacet, addNotification, isPending }}>
{children}
</NotificationContextBad.Provider>
)
}
 
// ✅ Better: Using startFacetTransition avoids unnecessary re-renders
type NotificationContextGood = {
notificationsFacet: Facet<string[]>
addNotification: (message: string) => void
}
 
const NotificationContextGood = createContext<NotificationContextGood | null>(null)
 
export const NotificationProviderGood = ({ children }: { children: React.ReactNode }) => {
const [notificationsFacet, setNotifications] = useFacetState<string[]>([])
 
const addNotification = (message: string) => {
// ✅ Use startFacetTransition: no isPending state, no re-renders
startFacetTransition(() => {
setNotifications((current) => (current !== NO_VALUE ? [...current, message] : [message]))
})
}
 
return (
<NotificationContextGood.Provider value={{ notificationsFacet, addNotification }}>
{children}
</NotificationContextGood.Provider>
)
}

How Transitions Work​

When you use useFacetTransition or startFacetTransition:

  1. Batching - Facet updates are batched together using batchTransition internally (a special variant that ensures all tasks run at the end of the transition, maintaining separate task queues for transition and non-transition updates)
  2. Priority - React treats these updates as low priority (interruptible)
  3. Concurrent rendering - React can pause and resume the work
  4. User responsiveness - High-priority updates (like user input) can interrupt transitions
  5. Task Queue Separation - Transition tasks are queued separately from regular tasks, ensuring proper priority ordering and preventing transition updates from blocking urgent updates

Nested Transitions​

Transitions can be nested within each other:

  • Inner transitions inherit the transition context from outer transitions
  • Task queues are maintained separately for transition and non-transition contexts
  • Tasks flush when the outermost transition completes
  • This allows for complex update patterns while maintaining UI responsiveness
tsx
import { useFacetState, startFacetTransition } from '@react-facet/core'
 
const ComplexUpdate = () => {
const [dataFacet, setData] = useFacetState<string[]>([])
 
const handleUpdate = () => {
// Outer transition
startFacetTransition(() => {
const partialData = processFirstBatch()
setData(partialData)
 
// Inner transition - will complete when outer completes
startFacetTransition(() => {
const finalData = processSecondBatch()
setData(finalData)
})
})
}
 
return <button onClick={handleUpdate}>Update</button>
}
 
const processFirstBatch = () => ['item1', 'item2']
const processSecondBatch = () => ['item1', 'item2', 'item3']

Error Handling​

If a facet update throws an error within a transition, the behavior is:

  • All remaining queued tasks in the current batch are cancelled
  • The task queue is cleared to prevent cascading errors
  • The error is re-thrown for you to handle

Always ensure your facet updates handle errors appropriately:

tsx
import { useFacetState, startFacetTransition } from '@react-facet/core'
 
const SafeUpdate = () => {
const [dataFacet, setData] = useFacetState<string[]>([])
const [errorFacet, setError] = useFacetState<string | null>(null)
 
const handleLoad = () => {
startFacetTransition(() => {
try {
const result = riskyComputation()
setData(result)
setError(null)
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error')
}
})
}
 
return <button onClick={handleLoad}>Load Data</button>
}
 
const riskyComputation = (): string[] => {
if (Math.random() > 0.5) throw new Error('Computation failed')
return ['data']
}

Performance Benefits​

Without transitions:

tsx
// Heavy update blocks the UI
setData(expensiveComputation())
// User interactions are delayed until this completes

With transitions:

tsx
startTransition(() => {
setData(expensiveComputation())
})
// User interactions remain responsive
// Heavy update happens in the background

Best Practices​

  1. Don't transition urgent updates - Keep critical UI feedback (like input fields) outside transitions
  2. Show pending state - Use isPending to indicate background work when using useFacetTransition
  3. Handle errors explicitly - Always wrap risky computations in try-catch blocks within transitions
  4. Combine with useFacetMemo - Cache expensive derivations within transitions for better performance
  5. Test on slower devices - Transitions shine on lower-end hardware
  6. Use startFacetTransition for shared state - Avoid unnecessary provider re-renders when isPending isn't needed
  7. Don't include startTransition in dependency arrays - The callback from useFacetTransition is stable

Comparison: Hook vs Function API​

FeatureuseFacetTransitionstartFacetTransition
Usage locationInside React componentsAnywhere (components or utils)
Returns pending stateYes (isPending)No
Re-renders on pendingYesN/A
Callback stabilityStable (safe in deps arrays)N/A
Best forInteractive UI with feedbackFire-and-forget updates
Example use caseSearch with loading spinnerBackground data refresh
Error handlingErrors cancel remaining tasks and re-throwErrors cancel remaining tasks and re-throw
Performance Tip

Use transitions for updates that affect large portions of your UI or trigger expensive computations. This keeps your app feeling snappy even during heavy updates.