Hacking Shared Mutable React Refs and Solving Realtime Performance Issues Inspired by Making Games

looking into heavy react components that crunch on deeply nested data very frequently

·

5 min read

While optimizing React performance is often a matter of breaking down complex data structures or using memoization and useCallback, there are some cases very specific use cases where performance becomes a critical factor and none of the mentioned ways solve the problem. In these extreme use cases, even small improvements can have a big impact on the user experience.

If you haven't read my previous blog on trivial performance optimization strategies, I highly recommend reading it!

I've personally encountered several real-world applications & business use cases where performance was a major issue despite memorizing and doing off-the-shelf performance improvements. Here are some examples of some of those cases...

  • A real-time dashboard with tons of visualizations made with react components consuming data from a websocket that has a high frequency of messages at a rate of ~15 messages/second (this blog is going to cover this topic)

  • Mounting and unmounting heavy react components efficiently without sacrificing UX

We'll explore some of these scenarios and dive into practical strategies to solve the problem.

⚠️ Disclaimer ⚠️

Some of the methods recommended in this blog might be hacky, so use them with care and use them only when you are unsatisfied with trivial performance improvement methodologies

About React Refs

refs are usually used in react to store the reference to an HTML DOM node that you can later use to focus or perform operations on the node like focusing and accessing other DOM attributes.

useRef() is useful for more than just storing DOM. It’s handy for keeping any mutable value. The best part about refs is that they don't trigger a re-render, they just store the value until the lifetime of the component.

function CounterWithRef() {
  const [forceUpdate, setForceUpdate] = useState(false);
  const counterRef = useRef({ count: 0 });

  const handleCounterClick = () => {
    counterRef.current.count++;
  };

  const handleRerenderClick = () => {
    setForceUpdate(!forceUpdate)
  };

  return (
    <div className="App">
      <button onClick={handleCounterClick}>count up</button>
      <button onClick={handleRerenderClick}>force re-render</button>
      <p>{counterRef.current.count}</p>
    </div>
  );
}

a demo for the above code block

We can notice two things happening here...

  • updating ref data is performant and does not mutate the data, usually in most practical cases updating mutable data is faster than updating immutable data. In case of large and/or complex objects, creating a new copy of the object for every single change can be very costly and tedious.

      // in other words
      oldData.isUpdated = true // is faster & simpler than
      const updatedData = { ...oldData, isUpdated: true }
    
  • refs persist data during the component lifecycle till the component unmounts.

refs can also be used to store instances of a class or function that you need to access from within a component or to store a reference to an external data source such as an API.

Games and Mutable Data structures

Usually, a typical game(video game) has an internal loop/process that handles the game logic, updates the game state, and renders the game to the screen. It's basically just a function that runs repeatedly and frequently to create a smooth and responsive gameplay experience. It is called a game loop 🔁

# very basic game loop's pseudo code
repeat forever {
   get user input
   do calculations & process one frame
   draw everything on the screen
   wait until a frame's worth of time has elapsed
}

Here's how it would look in code if we were to make a game in JS.

const player = {
  position: { x: 0, y: 0 },
  type: 'wizard',
  jump: () => {},
  draw: () => {},
  ...otherPlayerAttributes
};

function update(progress) {
  if(leftArrowKeyPressed()) {
    player.position.x--;
  } else if (rightArrowKeyPressed()) {
    player.position.x++;
  }

  if(spaceKeyPressed()) {
    player.jump();
  }
}

function draw() {
  player.draw()
}

var elapsedTime = 0;
// main game loop
function gameLoop(timestamp) {
  const timePassed = timestamp - elapsedTime;

  // get user input & update the game state
  update(timePassed);

  // draw stuff on the screen
  draw();

  elapsedTime = timestamp;
  // wait until a frame is processed
  window.requestAnimationFrame(gameLoop);
}
window.requestAnimationFrame(gameLoop);

The gameLoop function ideally would run at 60 fps for a smoother gameplay experience. Imagine a complex game where hundreds of game objects, creating a deep copy of the game objects' state on each iteration of the game loop becomes impractical. This is exactly when immutable objects should be ignored.

Allocating Objects is the slowest thing to do in a game loop, so we'd have to forget about immutability while making games. To generalize, we have to forget immutability where creating/modifying objects by creating a copy of them is a bottleneck to your application's performance.

Immutable data structures become useless when we need to frequently update it

Here the player object is created only one time and shared all over the game, so that other game objects can mutate it, for example, where there is a collision between an enemy object and the player object, the enemy object can directly reduce the player's health by player.health -= 5 .

Since we are creating mutable objects and sharing/allowing other game objects to perform different game logics, we call them "shared mutable objects". Shared mutable objects are widely used in many games and game engines, but should be used very carefully since they can lead to race conditions and concurrency errors.

Solving a Practical Usecase

Now that we know about trivial mechanisms to optimize in react and shared mutability of data, let's solve a hypothetical problem.

Assume you are tasked to build a real-time analytical dashboard where...

  • there are two views, a real-time dashboard view, and an on-demand tabular view

  • the dashboard should contain multiple react components for data visualization on a single screen

  • the data for the dashboard is frequently updated through a websocket at a frequency of 15 messages per second at regular intervals

  • the data for the tabular view should be static and only be updated when the user clicks the refresh button on the UI

  • the data from websocket can be deeply nested and there is very high randomness in the contents of the messages although the data structure is the same

We'll apply and learn how we could solve this problem in the next blog post!