JavaScript Equality Under the Lens: Enhancing React’s Dependency Checks - Part 2

JavaScript Equality Under the Lens: Enhancing React’s Dependency Checks - Part 2

In a previous blog, JavaScript Equality Under the Lens: Enhancing React’s Dependency Checks, we delved into the nuances of JavaScript data types and their equality checks. We explored values with identity and those without, examining how JavaScript stores objects by reference rather than by value. To address object comparison within React's dependency checks, we devised a method to fetch keys from an object and compare their values, though this method was limited to one level deep.

In this blog, we'll talk about a new way to do equality checks that will allow us to go n levels deep (rather than just one level) and will still be applicable not only to simple data types like numbers and strings, but also complex structures like objects and arrays. For this approach, we'll rely on an advanced but straightforward technique for value-based comparison of objects using JSON.stringify and JSON.parse.

Revisiting Object Equality: The Challenge

JavaScript’s intrinsic method of object comparison works by comparing memory references. This means two distinct objects with identical properties and values are considered unequal:

const objectOne = { key: "value" };
const objectTwo = { key: "value" };

console.log(objectOne === objectTwo);  // returns 'false'

In our previous blog, we created a function to check equality by manually comparing object keys and values. However, this function was limited to a shallow comparison:

function shallowEqual(obj1, obj2) {
  const keys1 = Object.keys(obj1);
  const keys2 = Object.keys(obj2);

  if (keys1.length !== keys2.length) return false;

  for (let key of keys1) {
    if (obj1[key] !== obj2[key]) return false;
  }

  return true;
}

While this approach was somewhat effective, it fell short when dealing with nested objects and arrays. This is where JSON.stringify and JSON.parse come into play.

The Magic of JSON.stringify and JSON.parse

In JavaScript, JSON.stringify converts a JavaScript object to a JSON string, while JSON.parse parses a JSON string to construct the JavaScript object. By converting objects into strings and comparing these string representations, we can bypass the idiosyncrasies of object reference comparison and handle nested structures efficiently:

const objectOne = { key: "value" };
const objectTwo = { key: "value" };

console.log(JSON.stringify(objectOne) === JSON.stringify(objectTwo)); // returns 'true'

And at the same time still return the original object using JSON.parse (if there's this use case).

Applying JSON Techniques in React

This approach is pivotal for React components' dependency checks. Let’s consider an advanced example where we use this technique in a custom hook:

import { useCallback, useEffect, useMemo, useRef, useState } from "react";

export type ObjType = {
  [key: string | number | symbol]: any;
};

/**
 * This hook checks for equality even for nested objects or arrays. Only causes a re-render if the [obj] param changes.
 */
const useObjectEqualityChecker = (obj: ObjType) => {
  const renders = useRef(0);
  if (renders.current >= 10) {
    throw new Error(
      `Total Renders: ${renders.current}. A tad bit too much ey?`
    );
  }
  const _internalStringify = useCallback((o: any) => {
    return JSON.stringify(o);
  }, []);

  const _objString = _internalStringify(obj);
  const currentObjRef = useRef(_internalStringify(obj));
  const [currentObj, setCurrentObj] = useState<string>(_internalStringify(obj));

  useEffect(() => {
    if (_objString) {
      try {
        if (currentObjRef.current !== _objString) {
          renders.current += 1;
          currentObjRef.current = _objString;
          setCurrentObj(_objString);
        }
      } catch (e) {
        // left blank intentionally. No need for any re-render.
      }
    }
  }, [_objString]);

  return useMemo(() => {
    return JSON.parse(currentObj);
  }, [currentObj]);
};

export default useObjectEqualityChecker;

This hook can come in handy when building components that take arrays or objects as props, where a developer might have forgotten to use useCallback or useMemo therefore returning different memory references every render.

Why This Approach is Better

  1. Value-Based Comparison:
  • Unlike reference-based comparison, the JSON-based approach guarantees that objects with the same properties and values will be considered equal, thereby avoiding unexpected re-renders in React.
  1. Depth Coverage:
  • This method seamlessly handles nested objects and arrays, ensuring accurate and deep comparisons without the need for custom deep equality functions.
  1. Simplicity and Clarity:
  • The technique is straightforward to implement, making your code more readable and less error-prone. It reduces the cognitive load for developers trying to understand object equality logic.
  1. Consistency:
  • Standardized behavior of JSON.stringify ensures consistency across different environments and usages, avoiding potential pitfalls of manually deep-checking objects or relying on third-party libraries.

Performance Implications

Though the JSON approach is powerful, it comes with performance trade-offs, especially for larger objects or frequent comparisons:

  1. Serialization Overhead:
  • Serializing and deserializing objects involve significant overhead compared to reference-based comparisons. For large and complex data structures, this could lead to performance bottlenecks.
  1. Frequency of Operation:
  • If used excessively in performance-critical sections of your application, the cumulative impact might be noticeable. However, for infrequent or isolated checks, the performance impact might be negligible.
const largeObject1 = { /* a large complex structure */ };
const largeObject2 = { /* a similarly large complex structure */ };

// Comparing using reference
console.time('Reference Comparison');
console.log(largeObject1 === largeObject2);
console.timeEnd('Reference Comparison');

// Comparing using JSON
console.time('JSON Comparison');
console.log(JSON.stringify(largeObject1) === JSON.stringify(largeObject2));
console.timeEnd('JSON Comparison');

In most cases, the reference comparison will be significantly faster.

Conclusion

No one-size-fits-all solution exists in software development. Understanding the trade-offs and careful application of these techniques will greatly enhance your ability to manage and optimize equality checks in JavaScript. This method of using JSON.stringify and JSON.parse for object comparison, especially in React, guarantees accurate value-based comparisons, making your components more reliable and efficient.

Until next time, happy coding!