BlogNotesAbout
moon indicating dark mode
sun indicating light mode

How to Deal with Circular Dependencies in React Hooks

July 27, 2020

TL;DR

Use this hook:

// This hook provides a ref which is perpetually up to date but will not
// trigger any renders. This is useful for resolving circular references in
// dependency arrays.
export default function useNoRenderRef(currentValue) {
const ref = useRef(currentValue);
ref.current = currentValue;
return ref;
}

Like this:

const [item, setItem] = useState(0);
const [queue, setQueue] = useState([]);
const queueNoRenderRef = useNoRenderRef(queue);
useEffect(() => {
// Access queue data using the special ref from the hook, but use the normal
// state writer. Since we don't have to include `queue` in the dependency
// array, this effect will not be run again when we use `setQueue`.
setQueue(queueNoRenderRef.current.concat(item));
}, [item, queueNoRenderRef]);
// since we updated queue, this effect will be run
useEffect(() => {
console.log("The queue was updated!");
}, [queue]);
console.log(queue); // [0, 1, 2, 3, ...]

We all love hooks, for good or for bad. With every advancement though, comes stumbling blocks.

The stumbling block that I have faced most often is an effect hook which is continually executed because its dependencies keep changing. I have found a way to deal with this, which I would like to share with you today.

Setup

Sometimes, I have an effect that looks like this:

const [item, setItem] = useState(0);
const [queue, setQueue] = useState([]);
useEffect(() => {
setQueue(queue.concat(item));
}, [item]);
console.log(queue); // [0], [1], ...

The First Problem

This hook is written in a way that conveys my intent perfectly. Whenever item changes, I want to add it to our queue. The hook even functions as I expect…almost.

Since queue is not in the dependency array, the hook will never have access to an updated value for it. That means that within the useEffect hook, queue will always be [].

This means that every time we update the queue, it is set to [item], overwriting any elements that were previously in the queue.

The Solution

Here is what is looks like when we fix that issue:

const [item, setItem] = useState(0);
const [queue, setQueue] = useState([]);
useEffect(() => {
setQueue(queue.concat(item));
}, [item, queue]);

The Second Problem

If you use the updated code, you will be stuck in an infinite loop, because the effect runs whenever the item or the queue are updated. Since the queue is updated when the effect is run, the effect will continually trigger itself.

The Solution

The best way I know of to deal with this is using a ref.

A ref is available anywhere in your functional component (or custom hook, whichever you are writing). That is because it is a reference which points at an object which has one property, current. The value of current is changed, but you never change the pointer itself. For more information, check out the docs.

We can use a ref like this:

const [item, setItem] = useState(0);
const queueRef = useRef([]);
useEffect(() => {
queueRef.current = queueRef.current.concat(item);
}, [item]);
console.log(queue); // [0, 1, 2, 3, ...]

Now we can to use queueRef.current anywhere to access the value.


What if our code looked like this?

const [item, setItem] = useState(0);
const queueRef = useRef([]);
useEffect(() => {
queueRef.current = queueRef.current.concat(item);
}, [item]);
useEffect(() => {
console.log("The queue was updated!");
}, []);
console.log(queue); // [0, 1, 2, 3, ...]

If you run this, you will find that you don’t get the log messages when you update the queue. You might think “oh, just put queueRef.current in the dependencies array!” If you use the eslint hooks plugin, you will be told that isn’t valid if you do that. This is because ref’s do not trigger renders (although technically in my experimentation, it did work in this case).

So, how do we resolve it?

Let’s add the queue state back to our code:

const [item, setItem] = useState(0);
const [queue, setQueue] = useState([]);
const queueRef = useRef([]);
useEffect(() => {
setQueue(queueRef.current.concat(item));
}, [item]);
useEffect(() => {
console.log("The queue was updated!");
}, [queue]);
console.log(queue); // [0]

We are almost there…We now have a way to read the queue which does not trigger another render because it isn’t in our dependency array. We also have our message working when we update the queue.

There is just one problem. We are back to the very first issue. We always set the queue to [item]! That is because we are not updating queueRef.current anywhere.

Let’s do that:

const [item, setItem] = useState(0);
const [queue, setQueue] = useState([]);
const queueRef = useRef([]);
queueRef.current = queue;
useEffect(() => {
setQueue(queueRef.current.concat(item));
}, [item]);
useEffect(() => {
console.log("The queue was updated!");
}, [queue]);
console.log(queue); // [0, 1, 2, 3, ...]

If you run this code, you will find that it works perfectly. It has everything we need. No extra renders, and our ref is always pointing at the right data.

We have solved the problem!

Refactoring

I have faced this often enough that I wanted a re-useable solution. Since that is exactly what hooks were created for, I decided to create a custom hook. This is what I came up with:

// This hook provides a ref which is perpetually up to date but will not
// trigger any renders. This is useful for resolving circular references in
// dependency arrays.
export default function useNoRenderRef(currentValue) {
const ref = useRef(currentValue);
ref.current = currentValue;
return ref;
}

Updated Code

If we use the hook if our code, it looks like this:

const [item, setItem] = useState(0);
const [queue, setQueue] = useState([]);
const queueNoRenderRef = useNoRenderRef(queue);
useEffect(() => {
setQueue(queueNoRenderRef.current.concat(item));
}, [item, queueNoRenderRef]);
useEffect(() => {
console.log("The queue was updated!");
}, [queue]);
console.log(queue); // [0, 1, 2, 3, ...]

This code works exactly as you would expect. Notice that you need to use the current property on your “no render ref” value, because it is a ref object. The hook does all of the work keeping the ref’s value up to date for us, and we now have a way to access the value within other hooks without triggering any re-renders.

If you are confused why there is no re-render even though queueNoRenderRef is in the dependency array, remember that is a ref object, which never changes. Instead, the value referenced by queueNoRenderRef.current is changed. The dependency array does not know about that though, so no re-render occurs.

The only reason I have included queueNoRenderRef in the dependency array is to satisfy the eslint hooks plugin. Technically, the code would still function properly even if it wasn’t in there.

If you would like to see this code functioning, I have created a codesandbox here.

Thank You, Friend

Thanks for stopping by, I really hope this helped you!


Brandon Conway
I enjoy learning about and writing code in many programming languages