A faster React.memo()


“Just show me the code”: click here

I recently spent time optimizing React code, and the obvious answer is, as always, React.memo(). So to speed things up I added a bunch of them everywhere because you should memo all the things by default anyway. This lead me to think if there was a way to make React.memo() faster. Not that it needs it, I just enjoy a bit too much performance. And the answer is yes.

And with the compiler not being shipped in React 19, it seems like we’re going to have to keep handling our own memoization for a bit longer, so I might as well share this one with you.

The Code

So to make memo faster (whose signature is React.memo(c: Component, f: CompareFunction), btw), we need to write a faster compare function than the one React has. The good news here is that React being a generic framework kinda needs to expect its users to do all sorts of funky stuff, whereas we, my friend, can tell our users to just not do anything funky because we won’t support that.

So let’s start with React’s implementation, and let’s see how we can make it faster.

function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) { return true; }
 
  if (typeof objA !== 'object' || objA === null ||
      typeof objB !== 'object' || objB === null) {
    return false;
  }
 
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);
 
  if (keysA.length !== keysB.length) {
    return false;
  }
 
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }
 
  return true;
}

The first thing I don’t like with this approach is the usage of Object.keys(). If we’re going to be calling this function very often, allocating 2 new arrays on each call is nuts. It’s the easy way, but it’s also the wrong way if the goal is performance (not that it should always be, most of the times, it’s readability). Whatever software you’re writing, unless you’re dealing with network requests, memory IO is always going to be the biggest cost. Allocating arrays in RAM is just one example of that. So let’s try to get rid of that.

Another section that I’m not a fan of is this prelude.

if (Object.is(objA, objB)) { return true; }
 
if (typeof objA !== 'object' || objA === null ||
    typeof objB !== 'object' || objB === null) {
  return false;
}

What I think happened here is they implemented a generic “shallow equals” function, and re-used it where needed, one place being React.memo(). One of the advantages that we have here is that we’re optimizing for one case, and we should therefore be specializing this function for React props. So let us assume these properties about React props:

  • They don’t have funky prototype chains.
  • They are not the same object: prop objects are always inline objects.
  • They are likely to be the same shape (monomorphic).
  • They are likely to be equal: most of the time, only a small fraction of the UI changes.
  • ⚠️ They are not null and always an object: <div /> is transformed to _jsx('div', {}).
  • ⚠️ Having prop={undefined} or no prop is functionally equivalent.

I have marked with ⚠️ the assumptions that are unsafe and would lead to an incorrect “generic shallow compare” function, and we’ll come back to these later. With that in mind, here is the implementation I landed on:

export function fastCompareUnsafe(a, b) {
  let aLength = 0;
  let bLength = 0;
 
  for (const key in a) {
    aLength += 1;
 
    if (!Object.is(a[key], b[key])) {
      return false;
    }
  }
 
  for (const _ in b) {
    bLength += 1;
  }
 
  return aLength === bLength;
}

As we can assume non-nullable objects, we were able to get rid of the prelude. Comparing the key count to check for equality is nice, but to avoid using Object.keys() like in the original we had to do it by iterating each key of the object. You might be tempted to propose that we return early in the last loop instead of just counting the keys:

  for (const _ in b) {
    bLength += 1;
 
    // Why not return?
    // if (b[_] != a[_]) {
    //   return false
    // }
  }

The problem problem with that is memory IO again: checking a[key] loads the value from memory, which is expensive. By avoiding the early return there, we can avoid touching a at all again. Depending on which JS engine we’re running on, we might even not be touching b again even if we’re iterating its keys, because most engines keep separate memory tables for an object keys and its values.

One other interesting change we were able to do, because we assumed that “they don’t have funky prototype chains”, is we were able to remove the Object.hasOwnProperty() call that React has:

  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];
    if (
      !hasOwnProperty.call(objB, currentKey) ||
      !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }

I’ll note that V8 and JavaScriptCore are able to optimize away the hasOwnProperty call, but SpiderMonkey can’t (yet), so this optimization is Firefox specific.

Safe version

Some of the assumptions we made above are unsafe however, in particular if you use the pattern 'key' in props in your codebase. So here is also the function without the unsafe assumptions, where we have added back the prelude and the check for the same keys including undefined ones:

export function fastCompare(a, b) {
  if (a === b) {
    return true;
  }
  if (!(a instanceof Object) || !(b instanceof Object)) {
    return false;
  }
 
  let aLength = 0;
  let bLength = 0;
 
  for (const key in a) {
    aLength += 1;
 
    if (!Object.is(a[key], b[key])) {
      return false;
    }
    if (!(key in b)) {
      return false;
    }
  }
 
  for (const _ in b) {
    bLength += 1;
  }
 
  return aLength === bLength;
}

How fast are we?

I’ve benchmarked against a bunch of existing modules, it seems like we’re the fastest1,2! There are various numbers in the table below, but the important part is that we’re about 2x faster than before.

I have compared 2 implementations, one where we don’t include the ⚠️ assumptions, and another one where we do.

{
  "fbjs/shallowEqual:equal":                 { t: 1322.67, stddev: 1.69 },
  "fbjs/shallowEqual:unequal":               { t: 1243.67, stddev: 1.24 },
 
  "fast-shallow-equal:equal":                { t: 1235.67, stddev: 36.80 },
  "fast-shallow-equal:unequal":              { t: 1241.33, stddev: 1.69 },
 
  "react:equal":                             { t: 1261,    stddev: 3.74 },
  "react:unequal":                           { t: 1249,    stddev: 1.63 },
 
  "shallowequal:equal":                      { t: 1172.67, stddev: 71.94 },
  "shallowequal:unequal":                    { t: 1194.33, stddev: 30.15 },
 
  "fast-equals.shallowEqual:equal":          { t: 1237.67, stddev: 27.35 },
  "fast-equals.shallowEqual:unequal":        { t: 325.67,  stddev: 1.88 },
  // ^ this one is surprisingly fast, but only for the "unequal objects"
  //   case which is the unlikely one, so we're not really interested in it.
 
  // Safe: does not include assumptions
  "romgrk-fastCompare:equal":                { t: 871.67,  stddev: 11.58 },
  "romgrk-fastCompare:unequal":              { t: 777,     stddev: 8.83 },
 
  "hughsk/shallow-equals:equal":             { t: 600.67,  stddev: 35.31 },
  "hughsk/shallow-equals:unequal":           { t: 562.67,  stddev: 3.68 },
  // ^ this one is pretty close to our implementation above! But it uses
  //   `===` for comparison instead of `Object.is`, which always returns
  //   false for `NaN === NaN`
 
  // Unsafe: includes ⚠️ assumptions
  "romgrk-fastCompareUnsafe:equal":          { t: 515,     stddev: 7.48 },
  "romgrk-fastCompareUnsafe:unequal":        { t: 445.33,  stddev: 1.24 },
}

1 Alright ok but we do need to admit that we’ve optimized for “react props”, not for general correctness. We return true for equals({ a: 1, b: undefined }, { a: 1, c: undefined }), which works for us but not for a generic compare function.
We do have fastCompare which is still the fastest and 1.5x faster than react if we only consider candidates that use safe assumptions. hughsk/shallow-equals is discarded due to its === use.

2 Alright and yes, I have excluded one package that was faster from this list. In my defense, it was shallow-equal-jit, which requires you to pass the object keys beforehand and only works if the keys stay the same, which does not work for react props. We can assume the keys are likely to be the same, but we can’t assume they are going to be the same.

Final notes

So anyway, if you’re not doing anything funky with your React props and are really way too interested in making your app performant, you can checkout the repo or pnpm install react-fast-memo.

The unsafe version might seem unuseful, but it has certain niche use-cases: if you’re comparing objects that you can 100% guarante have the same shape, then that one should be your pick.

One big asterisk though: on JavaScriptCore, the unsafe version is slower! Up-to-date benchmarks here.






Other notes

“It doesn’t matter for 99% of use-cases”: Yes you’re right.