Welcome to TWIL, our weekly software craft chronicle where development wisdom sprouts from hands-on encounters with code. This edition has Marisa demonstrating two React techniques. First, she guides us through Forcing Suspend State for Debugging, breaking down the utility of React DevTools to streamline browser debugging. Then we're learning how to Use React Suspense to Simplify Your Async UI, laying out how Suspense and error boundaries can enhance asynchronous user interfaces, affording a more fluid and responsive experience.
Forcing Suspend State for Debugging
You can utilize the Chrome DevTools to force your Suspended component into its suspend state to perform debugging in the browser. To do this, first navigate to the ⚛️ Components
tab:
Navigate to the specific component, and select it. Once there, you can click on the timer icon to invoke the suspended state:
Now your component will be in its suspended state!
- React
- Tools
Use React Suspense to Simplify Your Async-UI
React Suspense lets components “wait” for something before rendering.
For this example, ensure you have a version of React that supports concurrency - React (and React-DOM) v18 and up.
Setup Concurrent Rendering
In index.js
, or where you render the root of the application, update to use the following flow:
// Old Way
const rootElement = document.getElementById('root')
ReactDOM.render(<App />, rootElement)
// New Way
const rootElement = document.getElementById('root')
const root = ReactDOM.createRoot(rootElement)
root.render(<App />)
Error Boundaries
When you utilize Suspense in your component, you should also set up an Error Boundary component. This wrapper component will catch any JavaScript error thrown from its child components, and display a fallback UI.
Note: Error Boundaries must be Class Components.
class ErrorBoundary extends React.Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
componentDidCatch() {
// log the error to a server
}
render() {
return this.state.error ? (
<div>
There was an error.
<pre style={{ whiteSpace: 'normal' }}>{this.state.error.message}</pre>
</div>
) : (
this.props.children
)
}
}
Available library: https://github.com/bvaughn/react-error-boundary#readme
Simple Data-Fetching Example
In this example, we are fetching data on a specific Pokemon. When the App mounts, we want to kick off the request for the Pokemon information, displaying a loading message while we wait, and catching any errors that arise.
import React, { Suspense } from 'react'
// Utils & Service
import { ErrorBoundary, PokemonDataView } from '../utils'
import fetchPokemon from '../fetch-pokemon'
// "Fetch" our pokemon
let pokemon
let pokemonError
let pokemonPromise = fetchPokemon('pikachah').then(
// Handle success
p => (pokemon = p),
// Handle error
e => (pokemonError = e),
)
const PokemonInfoCard = () => {
// If there's a `pokemonError`, throw it
// This error will be caught by our `ErrorBoundary`
if (pokemonError) throw pokemonError
// If there's no `pokemon`, throw the promise
// This promise will be caught by our `Suspense` wrapper
if (!pokemon) throw pokemonPromise
// `pokemon` is available, render the information
return (
<div>
<div className="pokemon-info__img-wrapper">
<img src={pokemon.image} alt={pokemon.name} />
</div>
<PokemonDataView pokemon={pokemon} />
</div>
)
}
function App() {
return (
<div className="pokemon-info">
{/* Wrap our implementation in `ErrorBoundary`, this will handle catching and displaying errors for us */}
<ErrorBoundary>
{/* Wrap our `PokemonInfoCard` in a `Suspense` component, this will handle displaying the loading state */}
<Suspense fallback={<div>Loading Pokemon...</div>}>
<PokemonInfoCard />
</Suspense>
</ErrorBoundary>
</div>
)
}
Suspense
lets you specify what you want to display while the children in the tree below it are not yet ready to render. You can display a loading indicator or some placeholder UI with animations.
Writing a Generic Resource Factory
We can update our original fetch implementation and make it more reusable by turning it into a function that handles all of the cases.
const getFetchedResource = asyncFunc => {
// Create a `status` to be monitored for returns
let status = 'loading'
// Kick off the specified `asyncFunc`, handling
// successes and failures
let result
let promise = asyncFunc().then(
r => {
// Update `status` and `result`
status = 'success'
result = r
},
e => {
// Update `status` and `result`
status = 'error'
result = e
},
)
return {
read() {
// Use `status` to determine what to throw or return
if (status === 'loading') throw promise
if (status === 'error') throw result
if (status === 'success') return result
},
}
}
We can now update the rest of our original example to utilize this generic helper.
// Wrap our `fetchPokemon` call in our helper method
const resource = getFetchedResource(() => fetchPokemon('pikachu'))
function PokemonInfo() {
// Invoke the `read` function, triggering the API call
const pokemon = resource.read()
// `pokemon` is available, render
return (
<div>
<div className="pokemon-info__img-wrapper">
<img src={resource.image} alt={resource.name} />
</div>
<PokemonDataView pokemon={pokemon} />
</div>
)
}
Since our PokemonInfo
component is wrapped in our Suspense
and ErrorBoundary
, any result thrown from within getFetchedResource
will be taken care of.
Improve Suspense Loading States with useTransition
Now that we have generalized our implementation, making it more reusable, what happens when we want to get updated or new information? By default, React is optimistic when waiting for your suspending promise to resolve. This causes additional lag after the first render of your suspended component. After making a change to trigger the suspended promise, React is optimistic that the result will come within 100ms, but doesn’t take into account a longer waiting period. To help with this, you can use the useTransition
hook, allowing you to handle the pending state yourself.
A couple of notes here:
- Updates in a transition yield to more urgent updates such as clicks.
- Updates in a transition will not show a fallback for re-suspended content (that’s why we need to handle the
isPending
state), allowing the user to continue interacting while rendering the update.
function App() {
// State
const [pokemonName, setPokemonName] = useState(null)
const [pokemonResource, setPokemonResource] = useState(null)
// Transition
const [isPending, startTransition] = useTransition()
// Handle new submissions of `pokemonName`
const handleSubmit = (newPokemonName) => {
setPokemonName(newPokemonName)
// When a `newPokemonName` is entered, we want to start
// a new transition, creating a new resource to kick off
startTransition(() => {
setPokemonResource(getFetchedResource(newPokemonName))
})
}
return (
<div>
<PokemonForm onSubmit={handleSubmit} />
<hr />
{/* Utilize `isPending` here to update styles with the loading state */}
<div style={{opacity: isPending ? 0.6 : 1}} className="pokemon-info">
{pokemonResource ? (
<ErrorBoundary>
<Suspense
fallback={<PokemonInfoFallback name={pokemonName} />}
>
<PokemonInfo pokemonResource={pokemonResource} />
</Suspense>
</ErrorBoundary>
) : (
'Submit a Pokemon'
)}
</div>
</div>
)
}
The useTransition
hook is also useful when you only want to transition a certain piece of UI, leaving the remaining UI available to the user to interact with.
- React