Files
unicorn-utterances/content/blog/react-refs-complete-story/index.md
2020-10-21 19:31:28 -07:00

15 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

  • Stored using "ref" attribute
  • HTMLDivElement stored in "current"
  const elRef = React.useRef();

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

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

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

  • Full "Element.prototype" JS API available
    • Can be used to style the element
  const elRef = React.useRef();

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

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

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

Ref attribute accepts functional API

  const elRef = React.useRef();

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

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

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

Component References

  • Can pass "ref" to components
  • Property must not be called "ref"*
const Container = ({children, divRef}) => {
  return <div ref={divRef}/>
}

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

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

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

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

However, what happens if we try to switch to use the "ref" property name?

This code does not function

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

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

  React.useEffect(() => {
    console.log(elRef);
    // The following line will throw an error:
    // "Cannot read property 'style' of undefined"
   elRef.current.style.background = 'lightblue';
  }, [elRef])

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

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

  • *Can get it to use the "ref" property
  • Must use "forwardRef" API
  • Props can still be accessed using the first property
const Container = React.forwardRef(({children}, ref) => {
  return <div ref={ref}/>
})

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

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

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

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

Add Data to Ref

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