Trevedhek
Trevedhek

Reputation: 4378

Is using a ref in a custom hook feasible or advised for mutating the DOM?

I am working on incorporating a 3rd-party library into a React app, and hooks are making this very easy overall. However I have encountered a few issues, and I was hoping for some clarity on what is going on 'under the hood'.

For simplicity, let's say this is my 3rd-party code that mutates the DOM directly:

const renderStuff = (div, txt) => {
  if(div) div.innerHTML = txt;
}

And my component is something like this:

export const EffectRender = () => {
  const divRef = useRef();
  useRenderer(divRef, "Hello, world");
  return <div ref={divRef}></div>;
}

Here is the proposed custom hook:

const useRenderer = (ref, txt) => {
  const div = ref.current;
  useEffect(() => {
    renderStuff(div, txt);
  },[div, txt])
};

This works if one of the params (in this case, txt) is late-updated, say as a result of an async load. But the useEffect never recognizes when the ref.current value changes. So if the txt is set before ref.current is set (as in this case) the component never renders.

I understand that I can fix that by using setState in the custom hook, as in this example. But this starts to feel cumbersome.

I also understand that I could put the renderStuff call in a useEffect hook on the main component, and that guarantees the ref.current is set. So: useEffect(() => { renderStuff(divRef.current, txt); },[txt]); is fine.

My question really is whether this whole approach of using a ref inside a custom hook is a good idea. Is there a simpler way of getting the hook to recognize when the ref has changed? Or is this a case where custom hooks are not suited to the task?

Upvotes: 2

Views: 2148

Answers (2)

Trevedhek
Trevedhek

Reputation: 4378

Thank you Alvaro for clarifying the issue. I want to explain where my thinking went wrong here, assuming I'm not the only one to make this mistake.

My bad logic

We use effect hooks because the code is mutating the DOM, and useEffect exists to handle exactly this sort of side-effect.

We use ref hooks to connect to a given DOM element. The ref instance is like a singleton, in that the instance doesn't change during the life of the app. Only the ref.current property changes.

And directly under the relevant section in the hooks reference docs I read this:

Keep in mind that useRef doesn’t notify you when its content changes. Mutating the .current property doesn’t cause a re-render.

From that, I understood that relevant dependencies need to be passed to the useEffect call. And that the element to be updated (in ref.current) was one of these dependencies. And since changing the ref.current property doesn't trigger a re-render, it presumably wasn't triggering the useEffect call either.

BTW: this thinking was reinforced by es-lint demanding I add ref.current (in my case, div = ref.current) into the dependencies list: React Hook useEffect has a missing dependency: 'div'. Either include it or remove the dependency array. I always trust the linter, of course.

My conclusion: there was no simple way to use an effect hook to render to a ref instance. I needed to put the ref.current into a useState setter somehow. Ugly!

My core assumption

I assumed that the way the useEffect handles side-effects is not relevant. The effect hook is a black-box buried in the bowels of the typescript source code, and it moves in mysterious ways.

But the only thing 'buried' was this sentence in the use-effect docs:

The function passed to useEffect will run after the render is committed to the screen.

"After the render". Of course. That is how useEffect handles side-effects, by guaranteeing not to run until the DOM is ready to go.

The solution

As Alvaro's answer suggests, make sure to read the ref.current property inside the useEffect hook. The ref never changes, and ref.current is guaranteed to be already populated with a DOM element.

As usual, it is obvious in hindsight. Thanks again, Alvaro.

Upvotes: 0

Alvaro
Alvaro

Reputation: 9662

The problem is that const div = ref.current; in useRenderer is declared outside of the hook. In this moment of the cycle the ref is still not assigned, so its value is null.

If I understood the issue correctly, then the solution is to simply move the ref inside the useEffect callback. This is one of your proposals and I believe its the correct way:

const useRenderer = (ref, txt) => {
    useEffect(() => {
        const div = ref.current;
        renderStuff(div, txt);
    }, [txt]);
};

useEffect dependencies will not trigger when the ref the changes. This is ok in your case as the ref already has its assigned value when useEffect runs. However, if the ref changed and you needed to track the change the way to go is using a useState.

Upvotes: 1

Related Questions