Do You Really Need a useEffect?
6 min read
The more I work with React, the more paranoid I get around the misuse of the useEffect
hook. I first came to React before the era of hooks. Classes were a thing and I thought about component lifecycle through the concepts of componentDidMount
, componentDidUpdate
, componentWillUnmount
and the other related methods. The way I was then introduced to hooks was by a direct translation of these class-based methods, for example:
- For
componentDidMount
useEffect(() => {
// ...
}, [])
- For
componentDidUpdate
useEffect(() => {
// ...
}, [add, dependencies, here])
- For
componentWillUnmount
useEffect(() => {
// ...
return () => {
// cleanup function
}
}, [dependencies])
I then went on my merry way for a while, not really stopping to understand what useEffect
was actually designed to do, and whether sticking certain bits of code in it was really best practice. There's nothing like getting bogged down with an inconsistent bug to learn some valuable lessons the hard way though, so I've slowly been getting rid of my bad habits when it comes to using useEffect
. 😅
Happily, I've noticed there's been a lot more information out there in recent times, spreading awareness of this very misunderstood React hook. I came across this page in React's beta docs recently, which I found exceptionally useful, and would encourage everyone working with React to read. This post is basically me rewriting a bunch of the same information in my own words, as an internalisation exercise (with the added bonus of coming up with my own cheat sheet). You can decide to stick with me here, or go to the original source of truth. 😉
Key points
useEffect
should be used to synchronise your app with external systems. This is a point I made in Practical Tips When Using React’s Two Most Common Hooks (which you can read next if you're interested in some more practical tips of usinguseEffect
).- A lot of times, this might mean fetching data from an external API. You can do this within
useEffect
, but think about whether the framework you're using (if any), gives you more efficient built-in options for fetching data.- e.g. NextJS allows for server-side rendering (SSR) and useSWR (for client-side fetching). Read more about NextJS Page Rendering Options.
- If you do fetch data within
useEffect
, remember to add a cleanup function to avoid race conditions and memory leaks.
- A lot of times, this might mean fetching data from an external API. You can do this within
- Another use case for
useEffect
is for any code that needs to run because a component was displayed.- Otherwise, think about sticking your code in events.
- Don't transform your data for rendering within
useEffect
. This is inefficient as the steps React takes are as follows:- A component's state updates, which triggers...
- ...the running of the component's functions to determine what should be rendered.
- These changes are committed to the DOM...
- ...which updates what's displayed on the screen.
useEffect
is then run at this point.- If there's a state change in here, the whole process repeats.
- You therefore want to ensure your data transformations happens at the top level of the component, so they be run before changes are committed to the DOM. If you're worried about expensive calculations, wrap these in useMemo.
- Don't handle user events within
useEffect
. Based on the above steps, by the time we come touseEffect
, we will have lost the context in which the user event happened.- User events should be handled in event handlers instead.
- If you need to reset the state of an entire component tree, pass a different
key
to it. setState
changes due to changes in props should be done during rendering.- Think about batching the updating of several pieces of state within a single event.
- Think about whether lifting state up make sense, if you find yourself trying to synchronise state across various components.
Updating state based on props or state
Use derived state and set this at the top level of the component (i.e. calculate it during rendering).
function Game() {
const [name, setName] = useState("Hogwart's Legacy")
const [rating, setRating] = useState(5)
const gameReview = `${name} - ${rating} stars`
}
Use useMemo
to cache expensive calculations if you're worried about performance.
function GamesList({ allGames }) {
const [rating, setRating] = useState(5)
const filteredGamesByRating = useMemo(() => {
return getFilteredGamesByRating(allGames, rating)
}, [allGames, rating])
}
Reset all state on prop(s) change
Don't do this:
export function GameProfilePage({ gameId }) {
const [userReviews, setUserReviews] = useState('')
// Don't do this!
useEffect(() => {
setUserReviews('')
}, [gameId])
// Do this instead
}
Use the key
prop instead:
export function GameProfilePage({ gameId }) {
return <GameProfile gameId={gameId} key={gameId} />
}
function GameProfile({ gameId }) {
const [userReviews, setUserReviews] = useState('')
}
Note that only GameProfilePage
is exported. Other files do not need to be aware of how it is implemented with GameProfile
.
Use event handlers
Stick your logic in an event handler if you need it to run because of a specific interaction. If however, your logic needs to run because of what a user sees on screen, it can go into a useEffect
.
function GameReviewForm() {
// Only want this to run once, upon a user seeing the page on screen
useEffect(() => {
post('/analytics/track', { page: 'game_review_form' })
}, [])
// Call this on button click
function handleSubmit(e) {
e.preventDefault()
post('/api/createGameReview', { game, review })
}
}
Ensuring logic only runs once on mount
Remember that useEffect
s run twice in development, even if its dependency array is empty. To work around this:
let initialised = false
function App() {
useEffect(() => {
if (!initialised) {
initialised = true
loadData()
}
}, [])
// ...
}
Alternatively, you can check if we're running in the browser, before the app renders.
if (typeof window !== 'undefined') {
loadData()
}
function App() {
//...
}
Subscribing to an external store
I can't say I've ever had to use this, but it's good to know that React has a purpose-built hook called useSyncExternalStore
for subscribing to external stores (e.g. if data is from a browser API). What we're trying to avoid is manually syncing mutable data to React state via useEffect
.
Fetching data in useEffect
If you need to fetch data and it doesn't belong in an event handler, stick it in a useEffect
but remember to add a cleanup function.
function SearchGame({ query }) {
const [games, setGames] = useState([])
useEffect(() => {
let staleResults = false
getResults(query).then((jsonResults) => {
if (!staleResults) {
setGames(jsonResults)
}
})
return () => {
staleResults = true
}
}, [query])
}
If you're using a framework, think about whether there are more optimal ways of fetching data. These options may offer caching, race condition handling, error states etc. out of the box.