The React Hooks docs has a FAQ entry: “How can I do data fetching with Hooks?” where they link to this well written and detailed article by Robin Wieruch. There is however a shortcoming in that approach which affects correctness is some important situations.
As far as I can tell, there isn’t a widely available description of a more robust solution. There is nuance in getting it completely right so it seems worthwhile to fill this gap. I attempt to do that here.
You need something like
getDerivedStateFromProps
from Class Components. Use the useDerivedState()
custom hook provided below.Consider a component that fetching something from the server to display to the user or base further action on. You probably want to store the result of the data fetching as state in the component and the call to the server probably depends on some prop that is passed to the component.
This means that when the prop changes, we have to update the state correctly. That is, we need to keep the state in sync with the props.
For example, consider a social app where a user has a list of friends. You are building a component call
FriendList
that displays the user’s name along with a list of his/her friends . The component is passed in the current user as prop and has to fetch the list of friends from the server, which is stored as state. When the user changes, the list of friends needs to be fetched from the server again.
The approach taken by Robin Wieruch in the article linked above is to use
useEffect()
to sync the state to the prop. Here is some code that implements FriendList
using this approach.type User = {name: String; id: string}
type State = {loading: true} | {loading: false; friends: User[]}
function FriendList(user: User) {
const [state, setState] = React.useState<State>({loading: true})
React.useEffect(() => {
setState({loading: true})
serverCallToGetFriendList(user, friends =>
setState({loading: false, friends})
)
}, [user])
return (
<>
<h1>{user.name}, your friends are:</h1>
{state.loading ? (
'Loading...'
) : (
<ul> {state.friends.map(friend => ( <li> {friend.name} </li> ))} </ul>
)}
</>
)
}
When the user prop changes, the
useEffect()
is triggered, which updates the state to {loading:true}
and makes the call to the server. When the server call returns the state is updated to {loading:false, friends}
. Where friends
is the list fetched from the server.This approach has a problem. Effects are only run after the rendering of the whole component tree is completed, and after the browser completes painting and after the effects of child components are run. This means when the prop changes, the state is out of sync for all these operations.
The lesser issue here, which may be acceptable in some cases, is that the browser will actually briefly display out of sync data to the user. In our example, the browser may briefly display the previous user’s friends as belonging to the new user before it updates to ‘loading…’.
But there is a bigger issue. Given that the rendering was completed with out of sync data, child components that were passed the prop and state will see out of sync data. They might have scheduled effects based on that bad data.
These effects will run before the state is corrected and will likely be doing bad things.
The child components may also have set callbacks that will keep the bad data in their closures, and if triggered before the effect is run, will probably do something bad as well.
Even if the above issues don’t materialize in specific use cases, having out of sync data surface in this manner is intrinsically problematic and should be avoided.
Out of sync data should not be visible to child components. It makes reasoning about the program much harder when you have bad data moving around.
Since the problem with using
useEffect()
to sync the state to a prop is that it runs too late, we might consider useLayoutEffect()
which runs earlier. useLayoutEffect()
guarantees that the effect is run before the browser paints. This is better. Bad data is passed to child components, but will get fixed before the paint, so is not visible to the userBut what about effects scheduled by children based on the bad data? This is a tricky part of the semantics of
useLayoutEffect()
. It turns out that if you call setState()
inside useLayoutEffect()
it forces the effects to run before the state is updated and before the paint. (Note, this is the reverse of what happens normally. Usually effects run after the paint.) This is clarified by Brian Vaughn from the React team here.As part of this second, synchronous render [triggered by a setState() call in a layout effect], React also flushes the remaining/pending effects (including the passive ones- which would otherwise have not been flushed until a later frame).
So
useLayoutEffect()
is not a satisfactory solution.A perhaps non-obvious solution to the issue of keeping state in sync with props is to simply get React to destroy the underlying instance (and therefore state) of the component and create a new one when the prop changes. We can do this by changing the key of the React component whenever the prop changes. In our example, we could set the key of the
FriendList
component to user.id
. This effectively makes the prop a constant over the lifetime of the component instance, so we never have to worry about it changing.This works. Child components are never rendered with bad data. But it is a blunt tool. It may not be desirable to destroy the instance for a number of reasons. You will lose all the other state from the instance, for example.
Let us step back and consider what we are trying to do. We are trying to keep state in sync with props. There is an idiomatic to do this with Class Components and it is
getDerivedStateFromProps
. We have to implement something similar in Hooks. There is a FAQ entry addressing exactly this. The suggestion is to call setState()
directly during render when a prop change is detected to keep them in sync. Calling setState()
during render has the following semantics:[Y]ou can update the state right during rendering [a functional component]. React will re-run the component with updated state immediately after exiting the first render so it wouldn’t be expensive.
The key to this is that render will re-run immediately. In particular, before any child component renders, so child components never see out of sync state.
Applying this to
FriendList
we have the following:type User = {name: String; id: string}
type State = {loading: true} | {loading: false; friends: User[]}
function FriendList(user: User) {
const [state, setState] = React.useState<State>({loading: true})
// Save a local copy of the prop so we know when it changes.
const [localUser, setLocalUser] = React.useState(user)
// When the prop changes, update the local copy and the state that needs.
// to be in sync with the prop.
if (user !== localUser) {
setLocalUser(user)
setState({loading: true})
}
React.useEffect(() => {
if(!state.loading) return
serverCallToGetFriendList(user, friends =>
setState({loading: false, friends})
)
}, [user, state])
return (
<>
<h1>{user.name}, your friends are:</h1>
{state.loading ? (
'Loading...'
) : (
<ul> {state.friends.map(friend => ( <li> {friend.name} </li> ))} </ul>
)}
</>
)
}
We keep a local copy of the prop to check for changes. When a change is detected we update the local copy and the state to keep it in sync with the prop. Now when the prop changes the state is updated as soon as the render completes, and the render re-runs with the updated state.
Child components never see bad data. Note that we still have the
useEffect()
, but it is only used to make the server call, and not to update the state on prop change.Note however that since
setState()
is asynchronous, the state is fixed only after the function returns. This means that you still have to deal with out of sync state for the rest of the function. What if you schedule effects locally based on the bad data? Do we have to be careful about that? Turns out we do not. There is no official documentation, but testing reveals that effects scheduled during the render are discarded if
setState()
is called during that render. It also does not get counted as being run for the dependency list change calculations. It short, it is a no-op.We can actually do even better though and not have to worry about out of sync state even inside the render function. There some bookkeeping to take care of and this is a good time to extract the details to a custom hook.
Here is code for a custom hook
useDerivedState
(alluding to the Class Component equivalent):export function useDerivedState<State>(
onDepChange: () => State,
depList: any[]
): [State, (newState: State | ((state: State) => State)) => void] {
const [localState, setLocalState] = React.useState<
| {init: false}
| {
init: true
publicState: State
depList: any[]
}
>({init: false})
let currPublicState: State
if (
!localState.init ||
depList.length !== localState.depList.length ||
!localState.depList.every((x, i) => depList[i] === x)
) {
currPublicState = onDepChange()
setLocalState({
init: true,
publicState: currPublicState,
depList
})
} else {
currPublicState = localState.publicState
}
const publicSetState = React.useCallback(
(newState: State | ((state: State) => State)) => {
setLocalState(localState => {
if (!localState.init) throw new Error()
const publicState =
typeof newState === 'function'
? (newState as any)(localState.publicState)
: newState
return {...localState, publicState}
})
},
[]
)
return [currPublicState, publicSetState]
}
useDerivedState()
is intended to be used similarly to useState()
. It returns a [state, setState]
tuple just like setState()
. The difference is that instead of taking an initial state like setState()
, useDerivedState()
takes in a function to generate the state, and a dependency list. Whenever anything in the dependency list changes useDerivedState()
recalculates the state from the function and returns the new state synchronously.FriendList
now becomes:function FriendList(user: User) {
const [state, setState] = useDerivedState<State>(() => {
return {loading: true}
}, [user])
React.useEffect(() => {
if(!state.loading) return
serverCallToGetFriendList(user, friends =>
setState({loading: false, friends})
)
}, [state, user])
return (
<>
<h1>{user.name}, your friends are:</h1>
{state.loading ? (
'Loading...'
) : (
<ul> {state.friends.map(friend => ( <li> {friend.name} </li> ))} </ul>
)}
</>
)
}
Whenever the
user
prop changes, the state is reset to {loading:true}
synchronously which keeps the relationship between them easy to reason about.That hopefully gives you a new idiomatic way for data fetching in Hooks, and more generally, keeping state in sync with props. It is unfortunate that the official docs don’t provide more support for this and in fact suggests using
useEffect()
which can lead to serious errors. Strangely the docs actively discourage the right approach by going so far as to saying of calling setState()
directly while rendering: “You probably don’t need it, in rare cases that you do”. As we can see, it is needed for the correct approach to data fetching, which is pretty widespread use case if there ever was one.I'm building Qurika -- the future of eBooks.
Image Credit: ccPixs.com