Tracking Mutable Values Without Triggering Re-renders

In React functional components, useState and useReducer are the primary APIs for managing local state. Each call to their update functions forces a re-render. But not every value that changes over time needs to cause the component tree to re-execute — sometimes you only need to track a value internally, and re-rendering on every change is wasteful.

This article covers when useState is the wrong tool for the job, why plain variables fail to persist across renders, and how useRef() provides a mutable, persistent container that does not trigger re-renders. It also shows a pattern for forcing a deep re-render when you do need the UI to reflect a ref update.

The Problem With Too Many Re-renders

Consider a Card component that renders a title and body, where the body should toggle visibility when the header is hovered.

function Card (props) {
  const [toggled, setToggled] = useState(false);
  
  const handleToggleBody  = () => {
    setToggled(!toggled)
  }
  
  return (<section className="card">
    <h3 className="card__title" onMouseMove={handleToggleBody}>
       {props.title}
    </h3>
    
    {toggled && <article className="card__body">
      {props.body}
    </article>}
  </section>)
}

// Consumed as:
<Card name="something" body="very very interesting" />

When the mousemove event fires, the handler calls an update function that negates the previous toggle state. Because that update goes through useState, the component re-renders on every mousemove event. That is more work than needed for a simple hover toggle.

A common workaround is to store the toggle flag in a plain local variable instead of state.

function Card (props) {
  let toggled = false;
  
  const handleToggleBody  = () => {
    toggled = !toggled;
    console.log(toggled);
  }
  
  return (<section className="card">
    <section className="cardTitle" onMouseMove={handleToggleBody}>
       {title}
    </section>
    
    {toggled && <article className="cardBody">
      {body}
    </article>}
  </section>)
}

<Card name="something" body="very very interesting" />

This avoids the frequent re-renders, but introduces a different problem: local variables are re-initialized on every render. React does not track them. When the component re-renders for any other reason — such as a parent updating its own state — the variable resets to its default value.

Using variable in place of state
Using variable in place of state (Large preview)

To see this in practice: imagine an App component holding cardDetails in state, updating it after five seconds, and rendering a Card child. Inside Card, a toggled variable is set to true on mouseover. The internal value changes, but no re-render occurs. When the parent's state update triggers a re-render of Card, the variable is reset to false, and a useEffect can confirm the value was lost.

function Card (props) {
  let toggled = false;
  
  const handleToggleBody = () => {
    toggled = true;
    console.log(toggled);
  };

  useEffect(() => {
    console.log(“Component rendered, the value of toggled is:“, toggled);
  }, [props.title]);

  return (
    <section className=“card”>
      <h3 className=“card__title” onMouseMove={handleToggleBody}>
        {props.title}
      </h3>

      {toggled && <article className=“card__body”>{props.body}</article>}
    </section>
  );
}

// Renders the application
function App () {
  
  const [cardDetails, setCardDetails] = useState({
    title: “Something”,
    body: “uniquely done”,
  });

  useEffect(() => {
    setTimeout(() => {
      setCardDetails({
        title: “We”,
        body: “have updated something nice”,
      });
    }, 5000); // Force an update after 5s
  }, []);

  return (
    <div>
      <Card title={cardDetails.title} body={cardDetails.body} />
    </div>
  );
}

The sequence for a React component render is:

  • On initial render, all variables initialize to their defaults; state and refs are stored in React's internal store.
  • On an update, React reads the previous state and ref values, applies changes, and re-runs the component function.
  • Plain variables are re-initialized with their defaults on every run because React does not persist them.
  • The component is re-rendered with fresh variable values but preserved state and refs.

What useRef() Provides

React's useRef hook returns a mutable object with a single current property, structured like { current: value }. React persists this object across re-renders, making it useful for values that must survive renders without triggering new ones.

The same persistence applies when a ref references a DOM element. During re-renders, the DOM node may be replaced, and React ensures the ref points to the current element, avoiding inconsistencies.

To update a ref, you assign a new value to .current. This is safe for non-DOM values; when the ref references a DOM element, updating it manually can lead to unexpected results.

function User() {
  const name = useRef("Aleem");

  useEffect(() => {
    setTimeout(() => {
      name.current = "Isiaka";
      console.log(name);
    }, 5000);
  });

  return <div>{name.current}</div>;
}

Storing Component State in useRef

A ref can hold more than DOM references — it can store any value that changes frequently or that should not trigger a re-render on every update. Returning to the card example:

function Card (props) {
  
  let toggled = useRef(false);
  
  const handleToggleBody  = () => {
    toggled.current = !toggled.current;
  }
  
  return (
    <section className=“card”>
      <h3 className=“card__title” onMouseMove={handleToggleBody}>
        {props.title}
      </h3>

      {toggled && <article className=“card__body”>{props.body}</article>}
    </section>
  );
  </section>)
}

This works internally: the toggle value persists across renders without re-rendering the component. However, React does not expect refs to change, so updating one produces no visual feedback. No re-render happens at all.

Shallow vs. Deep Re-rendering

React has two rendering paths. A shallow render affects only the component itself, not its children. A deep render propagates to all descendants.

Refs update using the shallow mechanism. If a component stores user details in a ref and updates them after five seconds, the parent re-renders, but child components displaying the username and avatar will not update — they never receive new props or context, so their UI stays stale.

function UserAvatar (props) {
  return <img src={props.src} />
}

function Username (props) {
  return <span>{props.name}</span>
}

function User () {
  const user = useRef({
    name: "Aleem Isiaka",
    avatarURL: "https://icotar.com/avatar/jake.png?bg=e91e63",
  })

  console.log("Original Name", user.current.name);
  console.log("Original Avatar URL", user.current.avatarURL);
  
  useEffect(() => {
    setTimeout(() => {
      user.current = {
        name: "Isiaka Aleem",
        avatarURL: "https://icotar.com/avatar/craig.png?s=50", // a new image
      };
    },5000)
  })
  
  // Both children won't be re-rendered due to shallow rendering mechanism
  // implemented for useRef
  return (<div>
    <Username name={user.name} />
      <UserAvatar src={user.avatarURL} />
  </div>);
}

State updates via useState or prop changes from a parent use the deep mechanism. Children receive fresh values and re-render with the latest data.

function UserAvatar (props) {
  return <img src={props.src} />
}

function Username (props) {
  return <span>{props.name}</span>
}

function User () {
  const [user, setUser] = useState({
    name: "Aleem Isiaka",
    avatarURL: "https://icotar.com/avatar/jake.png?bg=e91e63",
  });

  useEffect(() => {
    setTimeout(() => {
      setUser({
        name: "Isiaka Aleem",
        avatarURL: "https://icotar.com/avatar/craig.png?s=50", // a new image
      });
    },5000);
  })
  
  // Both children are re-rendered due to deep rendering mechanism
  // implemented for useState hook
  return (<div>
    <Username name={user.name} />
      <UserAvatar src={user.avatarURL} />
  </div>);
}

Forcing a Deep Re-render After a Ref Update

If you need children to reflect a ref change, you can combine useRef with a state setter used solely to trigger re-renders. The state value itself is irrelevant; only the update function matters.

function UserAvatar (props) {
  return <img src={props.src} />
}

function Username (props) {
  return <span>{props.name}</span>
}

function User () {
  const user = useRef({
    name: "Aleem Isiaka",
    avatarURL: "https://icotar.com/avatar/jake.png?bg=e91e63",
  })

  const [, setForceUpdate] = useState(Date.now());
  
  useEffect(() => {
    setTimeout(() => {
      user.current = {
        name: "Isiaka Aleem",
        avatarURL: "https://icotar.com/avatar/craig.png?s=50", // a new image
      };
      
      setForceUpdate();
    },5000)
  })
  return (<div>
    <Username name={user.name} />
      <UserAvatar src={user.avatarURL} />
  </div>);
}

This pattern works, but it sidesteps the intended use of both hooks. You are storing state in a ref and calling useState purely for its re-render side effect — something a single useState could accomplish on its own. That said, the approach is not equivalent to using state directly: the setter forces a deep re-render without introducing state changes that affect the component's rendered output, keeping the component consistent across renders.

As for whether it is an anti-pattern — it is. You are taking advantage of useRef to hold local state and calling useState just to push updates down to children. Both behaviors could be achieved with useState alone. But when a value must update frequently without churning the render tree, with occasional UI synchronization, this hybrid approach offers a deliberate trade-off.

Finding the Right Balance Between Rendering and Performance

React's useState hook is the default tool for most component data, but it comes with a cost: every update triggers a re-render. If you are updating a value multiple times per second—say, tracking mouse coordinates or a progress indicator—those renders can pile up and degrade the user experience.

Local variables offer one escape hatch. They don't trigger renders, but they also don't survive a render cycle; React resets them on every pass, making them unreliable for data that must persist across renders. Refs sit in a useful middle ground: they are mutable objects whose values persist for the component's lifetime. Unlike useState, mutating a ref does not automatically cause a re-render. This makes refs a strong candidate for temporary or high-frequency data that should not force the component to repaint.

Forcing Updates Without State

Storing a value in a ref creates an obvious limitation: the UI won't reflect changes until something triggers a render. The solution is to pair the ref with a dummy state value whose only job is to force a re-render when needed. Calling an update function from useState—even one whose value you never read—tells React to run the component function again. At that point, the ref's current value is read, and the UI catches up.

That approach splits responsibilities cleanly:

  • A ref holds data that can change frequently without render overhead.
  • A non-reference useState updater, often called setForceUpdate, lets you trigger a render manually at the moments when the UI actually matters.

By combining both, you get a component that can update itself constantly with minimal repaints, while still being able to synchronize the DOM when necessary.

Composite Takeaways

For teams looking to optimize React components, these techniques form a coherent strategy:

  • Use refs for values updated frequently or held briefly, avoiding the expense of repeated useState calls.
  • Use a standalone useState updater when you need to force a re-render, regardless of the state value.
  • Apply both together to build components that update smoothly and stay responsive under rapid changes.

This pattern does not replace useState for everyday UI logic, but it does offer a considered alternative when render speed becomes a bottleneck.