Handle Deep Object Comparison in React's useEffect hook with the useRef Hook

Kent C. Dodds
InstructorKent C. Dodds

Share this video with your friends

Send Tweet

The second argument to React's useEffect hook is an array of dependencies for your useEffect callback. When any value in that array changes, the effect callback is re-run. But the variables object we're passing to that array is created during render, so our effect will be re-run every render even if the shape of the object is the same. So let's solve this by doing our own equality check from within the effect callback.

Instructor: [00:00] With our old query component, we were actually using this is equal from Lodash when we had this componentDidUpdate to compare the previous query and the previous variables with the new query and the new variables. We are doing that because the variables can actually be an object.

[00:16] As we can see here, the variables is an object that is going to be created new every single time. If we look at our useEffect, we are comparing the query and the variables. React is doing that for us, but it's not going to do a shallow comparison.

[00:32] It's actually going to do a direct comparison. It's going to say, previous variables, which it is tracking itself, and it's going to check that against variables. If that is not true, then it's going to rerun our callback.

[00:45] That's not what we want. This is actually always going to return false, because the way that people are using our component is, they're passing this variables object so every single time it is a brand new object. Every single time, our useEffect callback is going to be called.

[01:01] We need to do a little bit of extra work to make sure that that doesn't happen. What I'm going to do is, I'm going to bring back import is equal from Lodash is equal. Then inside of my useEffect hook, I'm basically going to simulate the same kind of thing that I was doing in here, which is not typically what we need to do.

[01:22] In our situation, because we're comparing two objects, and we need to make sure that they are deep equal, and we need to do a deep equality check on these, we're going to have to do it this way.

[01:33] I'm going to say, if is equal. I need to get my previous inputs, and then my current inputs, which is query and variables. Then I'll actually get rid of this right here, so I'll have our effects callback called every time and we'll do our own check right here.

[01:51] If they are equal, then we'll return. Now we need to find some way to know what the previous inputs were. Every single time this query is run, those inputs are going to change and we need to keep a reference to what those previous inputs were. We need to keep a reference.

[02:05] Let's try use ref. Down here below our useEffect, we're going to say const previous inputs equals use ref, and we'll use effect. After every single time our component is rendered, we'll say, previous inputs.current equals query and variables. Awesome.

[02:26] Then we're going to use the previous input.current rather than just simply previous inputs, and we'll compare the previous inputs with the new query and variables. Doing that, we can be sure that our effect is called every single time, but that we don't actually run our set state calls and our client calls unless the previous inputs are different from the new inputs.

[02:48] In review, what we did to make this work was, we ensured that our effect is called every time by removing the inputs argument here. We created a ref to keep track of the previous inputs on every render. Then we compared the previous inputs with the new ones, and if they are changed, then we'll go ahead and run our client to rerun the query.

Dean
Dean
~ 3 years ago

Curious why you needed a second useEffect, Couldn't you of just run this at the bottom of your first useEffect

const previousInputs = useRef()
previousInputs.current = [query, variables]
Kent C. Dodds
Kent C. Doddsinstructor
~ 3 years ago

That's a great point Dean! Could definitely use the same one. The reason I used a different one is probably because I knew that I was going to extract it to its own usePrevious hook in the future 😅

Timothy
Timothy
~ 3 years ago

Hi, this course seems to be a continuation of some other course. What course is this based on?

Kent C. Dodds
Kent C. Doddsinstructor
~ 3 years ago

Hi Timothy 👋 It is not a continuation of any other course. But if you want a primer on hooks you can watch my playlist here: https://kcd.im/hooks-and-suspense

Tommy Marshall
Tommy Marshall
~ 3 years ago

Nitpick, but feels weird to declare the previousInputs ref at the bottom. Also, I wonder if it'd be better to be able to pass a function as the second argument to useEffect that would act as the condition?

Kent C. Dodds
Kent C. Doddsinstructor
~ 3 years ago

I wonder if it'd be better to be able to pass a function as the second argument to useEffect that would act as the condition?

Unless something's changed and I'm unaware, that's not how useEffect works. It does not accept a function as the second argument.

malcolm-kee Roslan
malcolm-kee Roslan
~ 2 years ago

My understanding of the const keyword is that it will not be hoisted, thus referring previousInput ref before it is declared should throw an error.

Is it my understanding wrong?

Kent C. Dodds
Kent C. Doddsinstructor
~ 2 years ago

Yes, you would be correct except I'm accessing the value of previousInput in a closure that is called after render which is why it works. I did it this way because the effect needs to happen second and I wanted to keep things together.

Saloni Jain
Saloni Jain
~ 2 years ago

Can we not do this with useState instead of useRef ? We can keep track of the previous variables in state. Would it be any different ?

Guilherme
Guilherme
~ 2 years ago

How come previousInputs is not undefined in the first useEffect? You define it later in the code.

Guilherme
Guilherme
~ 2 years ago

Okay, I saw the other answer but apparently I can't delete my previous comment.

Bosn
Bosn
~ 2 years ago
function deepCompareEquals(a, b){
  // TODO: implement deep comparison here
  // something like lodash
  // return _.isEqual(a, b);
}

function useDeepCompareMemoize(value) {
  const ref = useRef() 
  // it can be done by using useMemo as well
  // but useRef is rather cleaner and easier

  if (!deepCompareEquals(value, ref.current)) {
    ref.current = value
  }

  return ref.current
}

function useDeepCompareEffect(callback, dependencies) {
  useEffect(callback, useDeepCompareMemoize(dependencies))
}
Phyllis Yen
Phyllis Yen
~ 2 years ago

Hi Kent, can you demonstrate how to use useMemo (by passing the flattened variables into the dependency array) in the component containing <Query /> and put the rendering of <Query /> inside the callback of useMemo so that <Query /> doesn't get re-rendered if the variables doesn't change?

François
François
~ 2 years ago

Hi Kent, Thanks for the course. Something doesn't click for me. In your useEffect, you're sometimes returning without proceeding with setState. To me, it seems like a violation of the Rules of Hooks https://reactjs.org/docs/hooks-rules.html) because the useState hook is somehow in a condition. Yet your code works and React doesn't complain. Is there a corner case where this wouldn't work?

Merijn
Merijn
~ 2 years ago

@François, the hooks are still called in the same order, just last calls were omitted. So this should just work, unless React expect all hook calls to be there in the next render... not sure about that. It might worth trying to see if this code construct survives the eslint rule (eslint-plugin-react-hooks). If not we are sure not to use this code construct. However something seems wrong with the Github link, the code of this chapter doesn't showup.

Avi Aryan
Avi Aryan
~ 2 years ago

Is it guaranteed that hooks run and finish in the order in which they are defined? That is, what happens if the useEffect setting the previousInput runs(& finishes) before the query useEffect? In that case, previousInput will be updated to the current prop values and the isEqual check will be always true.

I guess this is not a problem in the current scenario because both useEffect callbacks are synchronous until the use of previousInput variable. Am I getting this correct?

Brandon Aaskov
Brandon Aaskov
~ 2 years ago

@Avi I was curious about this too - it looks like useEffect calls are batched just like setting state from a useState hook (note if you're setting state inside an asynchronous method this is not the case)>

In the below code the component will only be re-rendered once after the initial render.

import React, { Fragment, useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function Component() {
  const [a, setA] = useState("a");
  const [b, setB] = useState("b");
  const myRef = useRef();

  console.log("a", a);
  console.log("b", b);
  console.log(JSON.stringify(myRef.current));

  useEffect(() => {
    if (a !== "aa") setA("aa");
    myRef.current = { ...myRef.current, a: "a" };
  });

  useEffect(() => {
    if (b !== "bb") setB("bb");
    myRef.current = { ...myRef.current, b: "b" };
  });

  return (
    <Fragment>
      <div>a = {a}</div>
      <div>b = {b}</div>
      <div>myRef = {JSON.stringify(myRef)}</div>
    </Fragment>
  );
}

function App() {
  return <Component />;
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

your console output here will be:

a a
b b
undefined
a aa
b bb
{{ current: { a: "a", b: "b" }}