Disconnected references after class component refactor - always use useRef!

The Context

On a large codebase (7+ years old) which originally used class components. As we have been going along, we have been incrementally refactoring these to be functional components.
As we were refactoring, we discovered a bug where the data from a ReactTable component wasn’t being exported to excel properly. You would click the download button and an empty excel sheet would be downloaded.

The Problem

We found the commit that caused the issue. Beforehand, everything is inline and working fine, whereas once we extract the onClick into a custom hook, the downloadTableData no longer works!
const CustomComponent = () => { let tableRef = undefined; - const onClick = () => { - tableRef.downloadTableData(); - }; + const { onClick } = useTable(tableRef); return ( <> <button type="button" onClick={onClick}> Show react table ref </button> <ReactTable data={data} columns={columns} ref={(input) => (tableRef = input)} /> </> ); }; + const useTable = (tableRef) => { + const onClick = () => { + tableRef.downloadTableData(); + }; + + return { onClick }; + }; export default CustomComponent;
The tricky thing about this bug, is that this refactor looks innocent!
There is one legacy line in the above code which might have stuck out to you, and also shows that this component used to be a class component:
let tableRef = undefined; // ❌ big code smell of a refactored class component
I never use the let keyword in my own React code, so this stuck out to me as a code smell (I prefer the slightly more immutable const). Before looking at how to solve this, let’s look at how this line of code came to be in our codebase.
Two years ago, this component, looked something like this:
class CustomComponent extends Component { constructor(props) { super(props); this.tableRef = undefined; } render() { const onClick = () => { this.tableRef.downloadTableData(); }; return ( <> <button type="button" onClick={onClick}> Show react table ref </button> <ReactTable data={data} columns={columns} ref={(input) => (this.tableRef = input)} /> </> ); } } export default CustomComponent;
After the refactor, we can see how the dev changed this.tableRef = undefined; into let tableRef = undefined;, and once he got the table component working, he left it like that.

A note on assignments within function parameters in JS

So, what changed in our original refactor to extract the custom hook?
The key here is that when passing a variable into a function, we are in fact assigning a new variable within the function with a new reference that points to the original variable.
As soon as we do any reassignment of the tableRef either side of the function boundary, we cut the link between tableRef that knows about the ReactTable and can download all the data, and the tableRef that is inside the hook attempting to call that method to download the excel data.
When we pass a variable into a function in JavaScript, we are actually assigning a new variable within the scope of the function. Initially, the new variable that is assigned points to the same reference, however any attempt to mutate that variable, only reassigns that new variable to a new value.
let variable = undefined; // Let's say this is stored at memory // address 0x001, which points to the // value of `undefined` when assigned. function (newVariable) { // Let's say `newVariable` is stored // at address 0x002, which points to // 0x001 (which points to `undefined`). newVariable = "New Value!" // Reassignment happens within the // function, now: 0x002 -> 0x003 // which stores "New Value!" }; someFunction(variable); // The attempt to pass in and mutate a // primitive value disconnects the refs.
Things change if we pass in an object and mutate it instead of reassigning:
let variable = { // Let's say `variable` is stored at name: "James" // memory address 0x001 }; function (newVariable) { // Let's say `newVariable` is stored // at address 0x002, which points to // 0x001 (which points to `undefined`). newVariable.name = "John" // This is mutation not reassignement so // `newVariable` points to 0x002 -> 0x001, // which `name` (at 0x001) to `"John"` }; someFunction(variable); // Passing in an object which is never // reassigned, the refs stay connected
💡 Here’s a link to a useful StackOverflow thread about the topic!
So, our function broke, because it depended on the fact that the tableRef can be safely mutated by ReactTable to add some method called downloadTableData to that ref. As soon as we pass our tableRef into the hook as a primitive value (undefined), we can no-longer attach this method without breaking the connection of references between the variable within the function and the original variable.
More specifically, these are the lines that cause the error:
const { onClick } = useTable(tableRef); return ( ... <ReactTable data={data} columns={columns} ref={(input) => (tableRef = input)} // ❌ reassignement here breaks ref // connection to variable in hook /> ); const useTable = (tableRef) => { // new variable is assigned pointing to original const onClick = () => { tableRef.downloadTableData(); // ❌ original variable is `undefined` // -> no downloadTableData method }; return { onClick }; }; export default CustomComponent;

Why “let tableRef = undefined” works, but is not OK!

In the class component: the tableRef is stored on the instance of the class, everything always points to the same reference on this (no new variable is created, so no chance off created disconnected references).
In the first functional component: there is only ever one variable created to watch the tableRef, so once again, no chance of disconnected references.
Initially I suspected that the first functional component was working only because everything was inside the scope of a single component render cycle, and the error somehow stemmed from differing refs between renders, but the above explanation is sufficient to explain why it works and why it breaks - my initial train of thought was completely wrong!
The way this ref is assigned works after the initial refactor from a class component to a functional component, however, the implementation is so prone to breaking easily without a dev noticing that I would consider this initial refactor to have set up the future team for failure. The final solution should have been used in the initial refactor from the start!
This is very interesting to me, as it’s an example of a bug being introduced across the scope of two refactors, which made it much trickier to spot.

The Solution

There is a handy hook called useRef to deal with this in React which returns the same alway ref between renders! This lets you access a value you don’t need for rendering.
useRef returns an object with a single property:
  • current: Initially, it’s set to the initialValue you have passed. You can later set it to something else. If you pass the ref object to React as a ref attribute to a JSX node, React will set its current property.
Importantly, here useRef returns an object with a property that we can mutate. Let’s see how we can use mutation, rather than reassignment so the the refs inside the hook and outside always remain connected:
- let tableRef = undefined; + const tableRef = useRef(undefined); // ✅ use useRef const { onClick } = useTable(tableRef); return ( ... <ReactTable data={data} columns={columns} ref={(input) => (tableRef.current = input)} // ✅ mutation doesn't break // connection between variable refs /> ); const useTable = (tableRef) => { // new variable is assigned pointing to original const onClick = () => { tableRef.current.downloadTableData(); // ✅ access property of object }; return { onClick }; }; export default CustomComponent;

The Code

I wrote a repo to showcase this here for you to play around with the understand the concept.