DEV Community

Cover image for ReactJS Hook Pattern ~Abandon Rendering~
Bishoy Semsem
Bishoy Semsem

Posted on • Originally published at bishoy-bishai.github.io

ReactJS Hook Pattern ~Abandon Rendering~

The Secret Weapon: How to "Abandon Rendering" in React with Hooks

Let's be honest, we've all been there. You've built a beautiful, feature-rich React application, but then you open up the profiler, and your heart sinks. You see that critical component, the one housing your complex logic or animation, re-rendering with every tiny mouse movement, every subtle data update, even when the UI visually doesn't need to change. It's like watching a perfectly good engine cycle pointlessly, burning fuel without moving the car.

In my experience, this isn't just an optimization problem; it's a fundamental challenge in how we think about state management in a declarative framework like React. We're taught that useState is for any mutable data, and useEffect is for side effects. And while that's true, it often leads to components becoming re-render hogs, especially when dealing with imperative logic, animations, or managing external resources.

The Illusion of React's "Everything Renders" Model

React's declarative nature is its superpower. You describe what you want to see, and React figures out how to get there. This involves reconciliation – comparing the new render tree with the old – and ultimately, updating the DOM. But what if a piece of internal component state, crucial for its logic, doesn't actually need to trigger this whole render process every time it changes?

Here's the thing: Not every mutable value needs to be reactive in the React sense. A value is "reactive" when its change causes React to re-evaluate and potentially re-render your component. useState is designed for reactive values. But there are countless scenarios where you need to manage a mutable value internally, update it frequently, and not have it trigger a re-render. Think about:

  • A setTimeout or setInterval ID.
  • A WebSocket instance.
  • A canvas context or a WebGL renderer instance.
  • An animation frame ID.
  • An event listener cleanup function.
  • A count of transient user interactions that don't need UI updates.
  • A mutable "dirty" flag for an unsaved form.

If you shove these into useState, you're forcing React to re-render, creating unnecessary work and potentially jank. This is where we learn to "abandon rendering" for specific, non-reactive pieces of state.

Enter useRef: The Silent State Manager

Most tutorials introduce useRef as a way to get a reference to a DOM element. While incredibly useful for that, it's merely scratching the surface. useRef is your secret weapon for holding any mutable value that doesn't trigger a re-render when it changes.

The .current property of a ref is mutable, and changing it does not cause your component to re-render. This is the core concept of "abandoning rendering" for specific internal state.

Let's look at an example. Imagine a component that needs to track an internal counter, but this counter doesn't directly display on the screen; it's used for some internal logic, maybe rate-limiting an API call or managing a complex drag state.

import React, { useRef, useState, useEffect } from 'react';

function RateLimitedButton() {
  const [clicks, setClicks] = useState(0);
  const clickCountRef = useRef(0); // This won't trigger re-renders
  const lastClickTimeRef = useRef(Date.now());
  const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);

  const handleClick = () => {
    const now = Date.now();
    if (now - lastClickTimeRef.current < 1000) { // If less than 1 second since last click
      console.log('Too fast! Waiting...');
      if (timeoutIdRef.current) {
        clearTimeout(timeoutIdRef.current); // Reset timeout
      }
      timeoutIdRef.current = setTimeout(() => {
        clickCountRef.current++;
        console.log(`Internal (non-rendering) click count: ${clickCountRef.current}`);
        setClicks(prev => prev + 1); // This will cause a re-render to update the display
        lastClickTimeRef.current = Date.now();
      }, 1000);
      return;
    }

    // Normal click processing
    clickCountRef.current++;
    console.log(`Internal (non-rendering) click count: ${clickCountRef.current}`);
    setClicks(prev => prev + 1); // This will cause a re-render to update the display
    lastClickTimeRef.current = now;
  };

  useEffect(() => {
    // Cleanup timeout if component unmounts
    return () => {
      if (timeoutIdRef.current) {
        clearTimeout(timeoutIdRef.current);
      }
    };
  }, []); // Run once on mount, cleanup on unmount

  return (
    <div>
      <p>Total Clicks (Reactive): {clicks}</p>
      <button onClick={handleClick}>Click Me (Rate-Limited)</button>
      <p>Check console for internal click count (non-reactive)</p>
    </div>
  );
}

export default RateLimitedButton;
Enter fullscreen mode Exit fullscreen mode

In this example:

  • clicks (useState) is reactive because we want to show the user the updated click count.
  • clickCountRef, lastClickTimeRef, and timeoutIdRef (useRef) manage internal logic that doesn't directly need to cause a UI update. Their values change frequently, but the component only re-renders when setClicks is called. This is the essence of decoupling internal state management from React's rendering cycle.

Beyond useRef: useEffect and Imperative Handles

useEffect also plays a crucial role here. It allows you to perform side effects (like setting up subscriptions, timers, or interacting with the DOM) without needing to store their identifiers in useState. You often use useRef inside useEffect to store values that need to persist across renders and don't need to trigger re-renders, like those setTimeout IDs.

Another powerful pattern, though less common, is useImperativeHandle. When you combine forwardRef with useImperativeHandle, a child component can expose specific methods or properties to its parent imperatively. This means the parent can call a child's method directly, bypassing the typical prop-driven re-render cycle for certain interactions.

Consider a video player component:

// Child: VideoPlayer.tsx
import React, { useRef, useImperativeHandle, forwardRef } from 'react';

interface VideoPlayerHandles {
  play: () => void;
  pause: () => void;
  seekTo: (time: number) => void;
}

interface VideoPlayerProps {
  src: string;
}

const VideoPlayer = forwardRef<VideoPlayerHandles, VideoPlayerProps>(({ src }, ref) => {
  const videoRef = useRef<HTMLVideoElement>(null);

  useImperativeHandle(ref, () => ({
    play: () => {
      videoRef.current?.play();
      console.log('Playing video imperatively!');
    },
    pause: () => {
      videoRef.current?.pause();
      console.log('Pausing video imperatively!');
    },
    seekTo: (time: number) => {
      if (videoRef.current) {
        videoRef.current.currentTime = time;
        console.log(`Seeking to ${time}s imperatively!`);
      }
    },
  }));

  return (
    <div>
      <video ref={videoRef} src={src} controls width="400" />
      <p>Video loaded from: {src}</p>
    </div>
  );
});

export default VideoPlayer;

// Parent: App.tsx
import React, { useRef } from 'react';
import VideoPlayer from './VideoPlayer';

function App() {
  const playerRef = useRef<VideoPlayerHandles>(null);

  const handlePlayClick = () => playerRef.current?.play();
  const handlePauseClick = () => playerRef.current?.pause();
  const handleSeekClick = () => playerRef.current?.seekTo(30); // Seek to 30 seconds

  return (
    <div>
      <h1>My Awesome Video App</h1>
      <VideoPlayer ref={playerRef} src="https://www.w3schools.com/html/mov_bbb.mp4" />
      <button onClick={handlePlayClick}>Play</button>
      <button onClick={handlePauseClick}>Pause</button>
      <button onClick={handleSeekClick}>Seek to 30s</button>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Here, the parent App component can control the VideoPlayer without having to pass a isPlaying prop that constantly changes and triggers re-renders. The VideoPlayer itself manages its internal state for playing/pausing, and the parent merely sends commands. This is a very powerful, often overlooked pattern for performance-critical interactions.

What Most Tutorials Miss: The Mindset Shift

The real lesson here isn't just how to use useRef, useEffect, or useImperativeHandle. It's about a shift in mindset. It's understanding that not all mutable data in a React component's scope needs to conform to React's reactive rendering model.

I've found that a lot of developers, myself included early on, treat useState as the default for any dynamic value. But true mastery comes from discerning:

  1. Does this value's change directly impact the UI that React manages? If yes, useState.
  2. Is this value an identifier, an instance of an external class, a mutable "scratchpad" for temporary calculations, or a function that needs to persist across renders but whose change shouldn't trigger a re-render? If yes, useRef.

This distinction allows you to build more performant applications, especially those with complex interactions, animations, or integrations with non-React APIs.

Pitfalls to Avoid

While powerful, these patterns come with caveats:

  • Overuse and Opacity: If you start putting all your state into useRef, you're essentially abandoning React's declarative benefits. Your component's behavior becomes harder to reason about, debug, and predict. State changes are no longer transparently managed by React.
  • Loss of Reactivity: Remember, a useRef value changing does not re-render your component. If you need a UI update based on that value, you'll still need to trigger a useState update or pass it as a prop. Don't hide genuinely reactive state in a ref.
  • Misunderstanding Identity: While useRef itself returns a stable object across renders, the .current property's value can be anything and can change. Be mindful of closure issues in useEffect if you're not careful about ref.current access.
  • Breaking Declarative Principles: The strength of React is its declarative nature. Leaning too heavily on imperative patterns (useRef for mutable state, useImperativeHandle) can pull you away from this and make your codebase less "React-y" and harder for new team members to grasp. Use these patterns judiciously, where performance or specific imperative interactions genuinely demand it.

Key Takeaways

"Abandoning rendering" isn't about fighting React; it's about deeply understanding its mechanisms and using its tools (useRef, useEffect, useImperativeHandle) to sculpt highly optimized components. It's recognizing that performance-critical parts of your application might benefit from managing certain kinds of state outside of React's typical re-render cycle.

It gives you precision control, allowing you to fine-tune exactly when and why your components update, leading to snappier UIs and happier users. So, the next time you encounter a performance bottleneck, pause and ask yourself: "Does this piece of data really need to cause a re-render?" Your profiler (and your users) will thank you.


> πŸš€ Read on My Blog

Top comments (0)