Files
unicorn-utterances/content/blog/react-refs-complete-story/index.md
2020-11-15 12:49:48 -08:00

27 KiB

title, description, published, authors, tags, attached, license
title description published authors tags attached license
React Refs: The Complete Story 2020-10-24T05:12:03.284Z
crutchcorn
react
javascript
cc-by-nc-sa-4

Programming terminology can be rather confusing. The first time I'd heard about "React Refs", it was in the context of getting a reference to a DOM node. However, with the introduction of hooks, the useRef hook has expanded the definition of "refs".

Today, we'll be walking through two definitions of refs:

We'll also be exploring additional functionality to each of those two defintions, such as component refs, adding more properties to a ref, and even exploring common code gotchas associated with using useRef.

As most of this content relies on the useRef hook, we'll be using functional components for all of our examples. However, there are APIs such as React.createRef and class instance variables that can be used to recreate React.useRef functionality with classes.

Mutable Data Storage

While useState is the most commonly known hook for data storage, it's not the only one on the block. React's useRef hook functions differently from useState, but they're both used for persisting data across renders.

const ref = React.useRef();

ref.current = "Hello!";

In this example, ref.current will contain "Hello!" after the intial render. The returned value from useRef is an object that contains a single key: current.

If you were to run the following code:

const ref = React.useRef();

console.log(ref)

You'd find a {current: undefined} printed to the console. This is the shape of all React Refs. If you look at the TypeScript definition for the hooks, you'll see something like this:

// React.d.ts

interface MutableRefObject {
	current: any;
}

function useRef(): MutableRefObject;

Why does useRef rely on storing data inside of a current property? It's so that you can utilize JavaScript's "pass-by-reference" functionality in order to avoid renders.

Now, you might think that the useRef hook is implemented something like the following:

// This is NOT how it's implemented
function useRef(initial) {
  const [value, setValue] = useState(initial);
  const [ref, setRef] = useState({ current: initial });

  useEffect(() => {
    setRef({
      get current() {
        return value;
      },

      set current(next) {
        setValue(next);
      }
    });
  }, [value]);
  
  return ref;
}

However, that's not the case. To quote Dan Apromov:

... useRef works more like this:

function useRef(initialValue) {
  const [ref, ignored] = useState({ current: initialValue })
  return ref
}

Because of this implementation, when you mutate the current value it will not cause a re-render.

Thanks to the lack of rendering on data storage, it's particularly useful for storing data that you need to keep a reference to, but don't need to render on-screen. One such example of this would be a timer:

  const dataRef = React.useRef();

  const clearTimer = () => {
    clearInterval(dataRef.current);
  };

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      console.log("I am here still");
    }, 500);

    return () => clearTimer();
  }, [dataRef]);

Visual Timer with Refs

While there are usages for timers without rendered values, what were to happen if we made the timer render a value in state?

Let's take the example from before, but inside of the setInterval, we update a useState that contains a number to add one to it's state.

 const dataRef = React.useRef();

  const [timerVal, setTimerVal] = React.useState(0);

  const clearTimer = () => {
    clearInterval(dataRef.current);
  }

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      setTimerVal(timerVal + 1);
    }, 500)

    return () => clearInterval(dataRef.current);
  }, [dataRef])

  return (
      <p>{timerVal}</p>
  );

Now, we'd expect to see the timer update from 1 to 2 (and beyond) as the timer continues to render. However, if we look at the app while it runs, we'll see some behavior we might not expect:

This is because the closure that's passed to the setInterval has grown stale. This is a common problem when using React Hooks. While there's a simple solution hidden in useState's API, let's solve this problem using mutations and useRef.

Because useRef relies on passing by reference and mutating that reference, if we simply introduce a second useRef and mutate it on every render to match the useState value, we can work around the limitations with the stale closure.

  const dataRef = React.useRef();

  const [timerVal, setTimerVal] = React.useState(0);
  const timerBackup = React.useRef();
  timerBackup.current = timerVal;

  const clearTimer = () => {
    clearInterval(dataRef.current);
  };

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      setTimerVal(timerBackup.current + 1);
    }, 500);

    return () => clearInterval(dataRef.current);
  }, [dataRef]);
  • I would not solve it this way in production. useState accepts a callback which you can use as an alternative (much more recommended) route:
  const dataRef = React.useRef();

  const [timerVal, setTimerVal] = React.useState(0);

  const clearTimer = () => {
    clearInterval(dataRef.current);
  };

  React.useEffect(() => {
    dataRef.current = setInterval(() => {
      setTimerVal(tVal => tVal + 1);
    }, 500);

    return () => clearInterval(dataRef.current);
  }, [dataRef]);

We're simply using a useRef to outline one of the important properties about refs: mutation.

DOM Element References

At the start of this article, I mentioned that refs are not just a mutable data storage method, but a way to reference DOM nodes from inside of React. The easiest of the methods to track a DOM node is by storing it in a useRef hook using any element's ref property:

  const elRef = React.useRef();

  React.useEffect(() => {
    console.log(elRef);
  }, [elRef]);

  return (
    <div ref={elRef}/>
  )

Keep in mind, the ref attribute is added and handled by React on any HTML Element. This example uses a div, but this applies to spans and headers and beyond, "oh my".

In this example, if we took a look at the console.log in the useEffect, we'd find an HTMLDivElement instance in the current property. Open the following StackBlitz and look at the console value to confirm:

Because elRef.current is now a HTMLDivElement, it means we now have access to the entire Element.prototype JavaScript API. As such, this elRef can be used to style the underlying HTML node:

  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.style.background = 'lightblue';
  }, [elRef]);

  return (
    <div ref={elRef}/>
  )

Alternative Syntax

It's worth noting that the ref attribute also accepts a function. While we'll touch on the implications of this more in the future, just note that this code example does exaclty the same thing as ref={elRef}:

  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.style.background = 'lightblue';
  }, [elRef]);

  return (
    <div ref={ref => elRef.current = ref}/>
  )

Component References

HTMLElements are a great use-case for refs. However, there are many instances where you need a ref for an element that's part of a child's render process. How are we able to pass a ref from a parent component to a child component?

By passing a property from the parent to the child, you can pass a ref to a child component. Take an example like this:

const Container = ({children, divRef}) => {
  return <div ref={divRef}/>
}

const App = () => {
  const elRef = React.useRef();

  React.useEffect(() => {
    if (!elRef.current) return;
   elRef.current.style.background = 'lightblue';
  }, [elRef])

  return (
    <Container divRef={elRef}/>
  );

You might be wondering why I didn't call that property ref instead of divRef. This is because of a limitations with React. If we try to switch the property's name to ref, we find ourselves with some unintended concequences.

// This code does not function as intended
const Container = ({children, ref}) => {
  return <div ref={ref}/>
}

const App = () => {
  const elRef = React.useRef();

  React.useEffect(() => {
    if (!elRef.current) return;
    // If the early return was not present, this line would throw an error:
    // "Cannot read property 'style' of undefined"
   elRef.current.style.background = 'lightblue';
  }, [elRef])

  return (
    <Container ref={elRef}/>
  );

You'll notice that the Container div is not styled to have a lightblue background. This is because elRef.current is never set to contain the HTMLElement ref. As such, for simple ref forwarding, you cannot use the ref property name.

How do you get the ref property name to work as expected with functional components?

You can use the ref property name to forward refs by using the forwardRef API. When defining a functional component, instead of simply being an arrow function, you pass forwardRef with the arrow function as it's first property. From there, you can access ref from the second property of the inner arrow function.

const Container = React.forwardRef((props, ref) => {
  return <div ref={ref}>{props.children}</div>
})

const App = () => {
  const elRef = React.useRef();

  React.useEffect(() => {
    console.log(elRef);
   elRef.current.style.background = 'lightblue';
  }, [elRef])

  return (
    <Container ref={elRef}/>
  );

Now that we are using forwardRef, we can use the ref property name on the parent component to get access to the elRef once again.

Class Component References

While I mentioned that we'll be using functional components and hooks for a majority of this article, I think it's important that I cover how class components handle the ref property. Take the following class component:

class Container extends React.Component {
  render() {
    return <div>{this.props.children}</div>;
  }
}

What do you think will happen if we try to pass a ref attribute?

const App = () => {
  const compRef = React.useRef();

  React.useEffect(() => {
    console.log(compRef.current);
  });

  return (
    <Container ref={container}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}

If you'd rather, you can also write App as a class component:

class App extends React.Component {
  compRef = React.createRef();

  componentDidMount() {
    console.log(this.compRef.current);
  }

  render() {
    return (
      <Container ref={this.compRef}>
        <h1>Hello StackBlitz!</h1>
        <p>Start editing to see some magic happen :)</p>
      </Container>
    );
  }
}

If you look at the console.log statement, you'll notice that it prints something like this:

Container {props: {…}, context: {…}, refs: {…}, updater: {…}…}
context: Object
props: Object
refs: Object
state: null
updater: Object
_reactInternalInstance: Object
_reactInternals: FiberNode
__proto__: Container

You'll notice that it prints out the value of a Container instance. In fact, if we run the following code, we can confirm that the ref.current value is an instance of the Container class:

console.log(container.current instanceof Container); // true

However, what is this class? Where are those props coming from? Well, if you're familiar with class inheritence, it's the properties coming from React.Component that's being extended. If we take a look at the TypeScript definition for the React.Component class, we can see some pretty familiar properties in that class:

// This is incomplete and inaccurate type definition shown for educational purposes - DO NOT USE IN PROD
class Component {
  render(): ReactNode;
  context: any;
  readonly props: Object;
  refs: any;
  state: Readonly<any>;
}

Not only do the refs, state, props, and context line up with what we're seeing in our console.log, but methods that are part of the class (like render) are present as well:

console.log(this.container.current.render);
ƒ render()

Custom Properties and Methods

Not only are React Component built-ins (like render and props) accessible from a class ref, but you can access data that you attach to that class as well. Because the container.current is an instance of the Container class, when you add custom properties and methods, they're visible from the ref!

So, if you change the class definition to look like this:

class Container extends React.Component {
  welcomeMsg = "Hello"

  sayHello() {
    console.log("I am saying: ", this.welcomeMsg)
  }

  render() {
    return <div>{this.props.children}</div>;
  }
}

You can then reference the welcomeMsg property and sayHello method:

function App() {
  const container = React.useRef();

  React.useEffect(() => {
    console.log(container.current.welcomeMsg); // Hello
    container.current.sayHello(); // I am saying: Hello
  });

  return (
    <Container ref={container}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}

Unidirectional Flow

While the concept of "universal directional flow" is a broader subject than what I originally wanted to cover with this article, I think it's important to understand why you shouldn't utilize the pattern outlined above. One of the reasons refs are so useful is one of the reasons they're so dangerous as a concept: They break unidirectional data flow.

Typically, in a React app, you want your data to go one way at a time.

A circle going from state, to view, to action, then back to state

Let's take a look at a code sample that follows this unidirectionality:

import React from "react";

class SimpleForm extends React.Component {
  render() {
    return (
      <div>
        <label>
          <div>Username</div>
          <input
            onChange={e => this.props.onChange(e.target.value)}
            value={this.props.value}
          />
        </label>
        <button onClick={this.props.onDone}>Submit</button>
      </div>
    );
  }
}

export default function App() {
  const [inputTxt, setInputTxt] = React.useState("");
  const [displayTxt, setDisplayTxt] = React.useState("");

  const onDone = () => {
    setDisplayTxt(inputTxt);
  };

  return (
    <div>
      <SimpleForm
        onDone={onDone}
        onChange={v => setInputTxt(v)}
        value={inputTxt}
      />
      <p>{displayTxt}</p>
    </div>
  );
}

In this example, because both the onChange property and value property are being passed in to the SimpleForm component, you're able to keep all of the relevant data in one place. You'll notice that none of the actual logic happens inside of the SimpleForm component itself. As such, this component is called a "dumb" component. It's utilized for styling and composability, but not for the logic itself.

This is what a proper React component should look like. This pattern of raising state out of the component itself and leaving "dumb" component comes from the guidance of the React team itself. This pattern is called "lifting state up".

Now that we have a better understanding of the patterns to follow, let's take a look at the wrong way to do things.

Breaking from Suggested Patterns

Doing the inverse of "lifting state", let's lower that state back into the SimpleForm component. Then, in order to access that data from App, we can use the ref property to access that data from the parent.

import React from "react";

class SimpleForm extends React.Component {
  // State is now a part of the SimpleForm component
  state = {
    input: ""
  };

  onChange(e) {
    this.setState({
      input: e.target.value
    });
  }

  render() {
    return (
      <div>
        <label>
          <div>Username</div>
          <input onChange={this.onChange.bind(this)} value={this.state.input} />
        </label>
        <button onClick={this.props.onDone}>Submit</button>
      </div>
    );
  }
}

export default function App() {
  const simpleRef = React.useRef();
  const [displayTxt, setDisplayTxt] = React.useState("");

  const onDone = () => {
    // Reach into the Ref to access the state of the component instance
    setDisplayTxt(simpleRef.current.state.input);
  };

  return (
    <div>
      <SimpleForm 
        onDone={onDone} 
        ref={simpleRef} 
      />
      <p>{displayTxt}</p>
    </div>
  );
}

However, the problem is that when you look to start expanding, you'll find managing this dual-state behavior more difficult. Even following the application logic is more difficult. Let's start taking a look at what these two components' lifecycle look visually.

First, let's start by taking a look at the simpleRef component, where the state is "lowered down" in the SimpleForm component:

Arrows pointing back and forth from App and SimpleForm to demonstrate the data going both directions

In this example, the flow of the application state is as follows:

  • App (and it's children, SimpleForm) render
  • The user makes changes to the data as stored in SimpleForm
  • The user triggers the onDone action, which triggers a function in App
  • The App onDone method inspects the data from SimpleForm

As you can see from the chart above and the outline of the data flow, you're keeping your data seperated across two different locations. As such, the mental model to modify this code can get confusing and disjointed. This code sample gets even more complex when onDone is expected to change the state in SimpleForm

Add Data to Ref

If you've never heard of the useImperativeHandle hook before, this is why. It enables you to add methods to

import React from "react";
import "./style.css";

const Container = React.forwardRef(({children}, ref) => {
  return <div ref={ref} tabIndex="1">
    {children}
  </div>
})

export default function App() {
  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.focus();
  }, [elRef])

  return (
    <Container ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}

https://stackblitz.com/edit/react-use-imperative-handle-demo-pre

  • useImperativeHandle hook allows properties to be added to ref
  • Can be used in combination with forwardRef
  • Only properties returned in second param are set to ref
import React from "react";
import "./style.css";

const Container = React.forwardRef(({children}, ref) => {
  const divRef = React.useRef();

  React.useImperativeHandle(ref, () => ({
    focus: () => {
      divRef.current.focus();
      console.log("I have now focused");
    }
  }))

  return <div ref={divRef} tabIndex="1">
    {children}
  </div>
})

export default function App() {
  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.focus();
  }, [elRef])

  return (
    <Container ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </Container>
  );
}

https://stackblitz.com/edit/react-use-imperative-handle-demo-post

Be cautious about using this in production. It breaks unidirectional data binding

  React.useEffect(() => {
    elRef.current.konami();
  }, [elRef])

https://stackblitz.com/edit/react-use-imperative-handle-demo-useful

React Refs in useEffect

  • useEffect only does the array check on re-render
  • Ref's current property set doesn't trigger a re-render
export default function App() {
  const elRef = React.useRef();

  React.useEffect(() => {
    elRef.current.style.background = "lightblue";
  }, [elRef]);

  return (
    <div ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </div>
  );
}

https://stackblitz.com/edit/react-use-ref-effect-style

However, what happens when you make the div render happen after the initial render. What do you think will happen here?

export default function App() {
  const elRef = React.useRef();
  const [shouldRender, setRender] = React.useState(false);

  React.useEffect(() => {
    if (!elRef.current) return;
    elRef.current.style.background = 'lightblue';
  }, [elRef.current])

  React.useEffect(() => {
    setTimeout(() => {
      setRender(true);
    }, 100);
  }, []);

  return !shouldRender ? null : ( 
    <div ref={elRef}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </div>
  );
}

https://stackblitz.com/edit/react-use-ref-effect-bug-effect

  const [minus, setMinus] = React.useState(0);
  const ref = React.useRef(0);

  const addState = () => {
    setMinus(minus + 1);
  };

  const addRef = () => {
    ref.current = ref.current + 1;
  };

  React.useEffect(() => {
    console.log(`ref.current:`, ref.current);
  }, [ref.current]);

  React.useEffect(() => {
    console.log(`minus:`, minus);
  }, [minus]);

https://stackblitz.com/edit/react-use-ref-not-updating

Here are some comments from Dan Apromov, of the React Core team:

https://github.com/facebook/react/issues/14387#issuecomment-503616820

https://twitter.com/dan_abramov/status/1093497348913803265

https://github.com/facebook/react/issues/14387#issuecomment-493677168

But what does Dan mean by "callback ref"?

Callback Refs

  • Remember, "ref" property accepts a function to access the node
  • Because it's not using "useEffect", code can execute in proper timing
  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      node.style.background = "lightblue";
    }
  }, []);

  return !shouldRender ? null : (
    <div ref={elRefCB}>
      <h1>Hello StackBlitz!</h1>
      <p>Start editing to see some magic happen :)</p>
    </div>
  );

https://stackblitz.com/edit/react-use-ref-callback-styling

  • Can still keep a traditional "useRef" reference
  const elRef = React.useRef();

  console.log("I am rendering");

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      node.style.background = "lightblue";
      elRef.current = node;
    }
  }, []);

  React.useEffect(() => {
    console.log(elRef.current);
  }, [elRef, shouldRender]);

https://stackblitz.com/edit/react-use-ref-callback-and-effect

useState Refs

  • Combining useState and Callback Refs
  • Will trigger a re-render
  • Works in useEffect
  const [elRef, setElRef] = React.useState();

  console.log('I am rendering');

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      setElRef(node);
    }
  }, []);

  React.useEffect(() => {
    console.log(elRef);
  }, [elRef])

https://stackblitz.com/edit/react-use-ref-callback-and-use-state

  • Can be used to impact reference using useEffect instead of inside of callback
 const [elNode, setElNode] = React.useState();

  const elRefCB = React.useCallback(node => {
    if (node !== null) {
      setElNode(node);
    }
  }, []);

  React.useEffect(() => {
    if (!elNode) return;
    elNode.style.background = 'lightblue';
  }, [elNode])

https://stackblitz.com/edit/react-use-ref-callback-and-state-effect

Conclusion