Writing a custom hook that syncs your internal state with props in React and TypeScript.

Writing a custom hook that syncs your internal state with props in React and TypeScript.

ยท

8 min read

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.

  1. React calls the SearchBox component (re-renders) and its child components with the stale internal state and the new query.
  2. React commits changes to the DOM -> updating the screen.
  3. React runs your effects.
  4. The effect calls setInternalState which triggers another re-render.
  5. 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,

  1. React calls (re-renders) the SearchBox component and its child components.
  2. During rendering, when the query prop doesn't match with the internalState, setInternalState(query) is called.
  3. setPrevQuery(query) is called and queues the next render with the updated state.
  4. Because setInternalState and setPrevQuery were called, React discards the returned JSX and starts the rendering process again. This time with the updated internalState and prevQuery.
  5. 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

  1. prevQuery and setPrevQuery -> move this inside our custom hook.
  2. 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 ๐Ÿป

ย