Writing a custom hook that syncs your internal state with props in React and TypeScript.
Let's say, for whatever reason, you ran into a scenario where you wanted to create an internal state that can be overwritten when a prop changes.
You might write it like this.
type SearchBoxProps = {
// the prop we want to derive
query: string
}
export function SearchBox({ query }: SearchBoxProps): JSX.Element {
const [internalState, setInternalState] = useState(query)
useEffect(() => {
setInternalState(query)
}, [query])
return (...)
}
This works but there is an issue with this approach. This post is going to be quite long. I wanted to take you through the journey of solving this issue and my thought process. If you're here for the solution snippet, feel free to jump to the end and you'll find it there. Otherwise, let's deep dive into it.
The issue with useEffect
When the query
prop changes, these things happen.
- React calls the
SearchBox
component (re-renders) and its child components with the stale internal state and the newquery
. - React commits changes to the DOM -> updating the screen.
- React runs your effects.
- The effect calls
setInternalState
which triggers another re-render. - React does the whole process from 1. to 3. again.
๐ก You can find these steps documented in the beta react doc.
It's not efficient and might cause some unintentional behaviors when React commits changes with stale values.
Is there a better way?
Of course. In order to avoid this behavior, you can remove useEffect
entirely and call setInternalState
directly during rendering.
export function SearchBox({ query }: SearchBoxProps): JSX.Element {
const [internalState, setInternalState] = useState(query)
setInternalState(query)
return (...)
}
"Wait, this wouldn't work.", you might say this and you are right.
It's calling setInternalState
every render regardless of the changes to the query
prop. We need to check if the query
has been changed or not compared to the previous render before setting the state.
Caching the previous state
We need a variable to store the previous value of the query
prop for comparison. We should use useState
in this case because we're gonna update the value during rendering.
export function SearchBox({ query }: SearchBoxProps): JSX.Element {
const [internalState, setInternalState] = useState(query)
// To cache the previous `query` value.
// Use `query` as the initial state.
const [prevQuery, setPrevQuery] = useState(query)
// This block runs only when the current `query` value
// does not match the previous render.
if (prevQuery !== query) {
// Check if the internalState already matches
// with the new `query` value.
// If not, overwrite the state with the current `query` value.
if (internalState !== query) {
setInternalState(query)
}
// Update the cache
setPrevQuery(query)
}
return (...)
}
What this code does?
When the query
prop changes,
- React calls (re-renders) the
SearchBox
component and its child components. - During rendering, when the
query
prop doesn't match with theinternalState
,setInternalState(query)
is called. setPrevQuery(query)
is called and queues the next render with the updated state.- Because
setInternalState
andsetPrevQuery
were called, React discards the returned JSX and starts the rendering process again. This time with the updatedinternalState
andprevQuery
. - React commits changes to the DOM -> updating the screen.
Why did we use useState
instead of useRef
to store the previous state?
Since we don't use the variable for rendering anything in the UI, we should be able to use useRef
and it should be better because setting values to a ref doesn't trigger a re-render, right?
Not really. The key is, we need to update the value during rendering. React expects components to be pure. Meaning, given the same props, state, and context your component should always return the same JSX.
(props, state, context) => JSX
Notice that there is no ref in the equation.
Writing or reading a ref during rendering is a pitfall that we should avoid because it can differ the output when given the same data. There is actually a full section about this in the React doc (see Pitfall at the end of the section).
Your app might still work. However, React internally relies on this assumption that your components are pure, so they might break in the future version of React. Basically like a time bomb.
Extracting the magic into a custom hook
Let's turn it into a custom hook that we can reuse throughout the app.
Analyze which parts need to be in our custom hook
The main part of the logic is this chunk.
const [prevQuery, setPrevQuery] = useState(query)
if (prevQuery !== query) {
if (internalState !== query) {
setInternalState(query)
}
setPrevQuery(query)
}
From the variables that we're using, you'll notice that the key ingredients are
prevQuery
andsetPrevQuery
-> move this inside our custom hook.query
prop -> receive this as a parameter.
We also call setInternalState
and use internalState
. This means we either have to also receive them as parameters or move the state declaration inside our hook.
I'll go with declaring the internal state inside our hook so the experience when using the hook will be similar to using a normal useState
.
In the end, we should be able to use it like this.
const [internalState, setInternalState] = useDerivedValue(query)
You can name the hook whatever you like, I'll name it useDerivedValue
.
Implementing the hook
We'll rename our variables to make them more generic and make more sense when reading the code.
export function useDerivedValue(value: string) {
const [internalValue, setInternalValue] = useState(value)
const [prevValue, setPrevValue] = useState(value)
if (prevValue !== value) {
if (internalValue !== value) {
setInternalValue(value)
}
setPrevValue(value)
}
return [internalValue, setInternalValue]
}
Looks good? Yeah, however, there are some improvements that we can do.
Starting with the return statement.
return [internalValue, setInternalValue]
We're creating a new array to mimic the return result of useState
. Actually, we can reuse the result of useState
without creating a new array.
export function useDerivedValue(value: string) {
const stateTuple = useState(value)
const [internalValue, setInternalValue] = stateTuple
const [prevValue, setPrevValue] = useState(value)
if (prevValue !== value) {
if (internalValue !== value) {
setInternalValue(value)
}
setPrevValue(value)
}
return stateTuple
}
Yay! ๐ป
It's time to use it
Now you can go back to our SearchBox
component and update the code.
export function SearchBox({ query }: SearchBoxProps): JSX.Element {
const [internalState, setInternalState] = useDerivedValue(query)
return (...)
}
Isn't this amazing? Look how beautiful it is now.
This is good and all, but we can do even better.
Make it truly reusable
Right now, our useDerivedValue
only works with string
values. What if we want to use it with numbers, objects, arrays, or other types?
Generics
For this, we'll need to use TypeScript generics.
export function useDerivedValue<T>(value: T) {
const stateTuple = useState(value)
const [internalValue, setInternalValue] = stateTuple
const [prevValue, setPrevValue] = useState(value)
if (prevValue !== value) {
if (internalValue !== value) {
setInternalValue(value)
}
setPrevValue(value)
}
return stateTuple
}
That's it. Now you can use numbers, objects, arrays, etc.
Or not yet.
Equality checks
Let's say we wanted to derive multiple props, so we passed an object to the hook.
const [{ internalFoo, internalBar }, setInternalState] = useDerivedValue({
internalFoo: foo,
internalBar: bar,
})
Writing it like this means that there will be a new object created every render. When we compare two anonymous objects ({} !== {} // true
) they won't be equal.
This won't really work because of how we compare the value with the previous one.
if (prevValue !== value) {
if (internalValue !== value) {
// ...
}
}
You will need to provide a way to make equality check works for every type and every use case. But how can we write code to handle every use case? The answer is we don't.
We'll just let the hook consumer determine how they want to compare the values by letting them send a comparison function to our hook.
export type UseDerivedValueOptions<T> = {
compareFn: (value: T, prevValue: T) => boolean
}
export function useDerivedValue<T>(
value: T,
options: UseDerivedValueOptions<T>
) {
const { compareFn } = options
const stateTuple = useState(value)
const [internalValue, setInternalValue] = stateTuple
const [prevValue, setPrevValue] = useState(value)
if (!compareFn(value, prevValue)) {
if (!compareFn(value, internalValue)) {
setInternalValue(value)
}
setPrevValue(value)
}
return stateTuple
}
Ok, look back at the SearchBox
component and make sure nothing is breaking.
export function SearchBox({ query }: SearchBoxProps): JSX.Element {
const [internalState, setInternalState] = useDerivedValue(query)
// ^
// TS2554: Expected 2 arguments, but got 1.
return (...)
}
We got a TypeScript error. It means useDerivedValue
declared that it needs two parameters but we're sending only one parameter.
This is because the options
that we just added was a required parameter. We'll turn it into an optional parameter with a default value.
export type UseDerivedValueOptions<T> = {
// We might receive other options in the future
// so we'll turn this into an optional property as well.
compareFn?: (value: T, prevValue: T) => boolean
}
// This will be our default compareFn to preserve the original behavior
const strictEqualityCheck = <T>(value: T, prevValue: T) => value === prevValue
export function useDerivedValue<T>(
value: T,
// Add an empty object as a default value,
// this will turn it into an optional parameter
options: UseDerivedValueOptions<T> = {}
) {
// Since options can be an empty array
// and compareFn can also be undefined,
// we'll have to add a default compareFn
// to preserve the original behavior
const { compareFn = strictEqualityCheck } = options
// ...
}
Now our hook is pretty much complete ๐ฅณ.
export type UseDerivedValueOptions<T> = {
compareFn?: (value: T, prevValue: T) => boolean
}
const strictEqualityCheck = <T>(value: T, prevValue: T) => value === prevValue
export function useDerivedValue<T>(
value: T,
options: UseDerivedValueOptions<T> = {}
) {
const { compareFn = strictEqualityCheck } = options
const stateTuple = useState(value)
const [internalValue, setInternalValue] = stateTuple
const [prevValue, setPrevValue] = useState(value)
if (!compareFn(value, prevValue)) {
if (!compareFn(value, internalValue)) {
setInternalValue(value)
}
setPrevValue(value)
}
return stateTuple
}
You can also extend this hook to suit your use case. For example, instead of always syncing the internal state with a prop, you want to reset the state when the prop changes. You can expose a state setter and receive it as another option property.
Feel free to leave comments and share your version of this hook for your use case.
Thank you for reading, and see you in the next one. Cheers ๐ป