Reputation: 4378
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
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.
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!
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.
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
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