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 theinitialValue
you have passed. You can later set it to something else. If you pass the ref object to React as aref
attribute to a JSX node, React will set itscurrent
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.