DEV Community

Christian Petersen
Christian Petersen

Posted on • Edited on

Pitfalls of Conditional Rendering and Refs in React

Refs can be really useful in situations where you want to store or access some underlying data from components. Maybe you want to access the DOM node of a component or element?

While working with a component which both fetched some data as well as rendering the UI for that data, including handling the loading and error states. I wanted to use a ref to access the DOM node of an element to do some animation. This is where I ran into a pitfall of refs and conditionals.

Say, for example, that we have a component that fetches some data asynchronously—that is, handling something that happens at a later time—and shows some UI for that data. It could look something like this:

function Post({ id }) {
  const { data } = useResource(`post/${id}`);

  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

Nice and neat! This looks really great. But now we want handle a special case. Let's say get the dimensions of the DOM node. This requires us to pass a ref to the component or element that we want to get the DOM node of. We can pass a ref to the article element to get its DOM node:

function Post({ id }) {
  const containerRef = React.useRef(null);
  const { data } = useResource(`post/${id}`);

  return (
    <article ref={containerRef}>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

To access the ref's value, we need to use a hook. It's important that we don't access refs in the body of the function component, always inside the body of a hook. We can use useEffect so that we can get the ref when the component has rendered and set the ref's value to the DOM node of the article element.

If you don't know how refs and hooks are related and how refs are updated, I recommend reading Manipulating DOM Elements With React Hook useRef() by Linus Spukas.

We access the ref and get the DOM node:

function Post({ id }) {
  const containerRef = React.useRef(null);
  const { data } = useResource(`post/${id}`);

  useEffect(() => {
    const node = containerRef.current;
    console.log(node); // => HTMLArticleElement object
  }, []); 

  return (
    <article ref={containerRef}>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

Later on, we decide that we also want to show a loading indicator when the post is loading the data. We can do this by adding a condition for when we want to render this loading indicator.

However, we know from the Rules of Hooks that we can't call hooks conditionally. So we place the condition after all the useEffect and before the article:

function Post({ id }) {
  const containerRef = React.useRef(null);
  const { data, loading } = useResource(`post/${id}`);

  useEffect(() => {
    const node = containerRef.current;
    console.log(node);
  }, []);

  if (loading) {
    return <Loading />
  }

  return (
    <article ref={containerRef}>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

We run the code and... wait... what happened to our ref? It returns null now! What happened?

It's actually quite straight forward; the component will render the Loading component first, because the value of loading is initially set to true as we are loading data. When the data has loaded—successfully or not—it will set the loading to false and render our article.

However, this also means that when the Post component is committed for rendering, it first resolves the ref and then runs out useEffect hook. Since the Loading component doesn't have any ref set to it, React will not set the value of the ref. And since the hook is only run once—because we didn't pass any dependencies to it—it will not set the ref when our component finally renders the article element with the ref set to it.

There are multiple ways we can solve this. One way, which can be perfectly legitimate in some cases, would be to move the loading state inside a parent element and pass the ref to the parent element, like so:

function Post({ id }) {
  const containerRef = React.useRef(null);
  const { data, loading } = useResource(`post/${id}`);

  useEffect(() => {
    const node = containerRef.current;
    console.log(node);
  }, []);

  return (
    <div ref={containerRef}>
      { loading ? <Loading /> : (
        <article>
          <h1>{data.title}</h1>
          <p>{data.body}</p>
        </article>
      ) }
    </div>
  );
}

This way we can be both schematically correct and get the ref again. This solved our problem And gives us the ref to another element.

But we can't access the article element directly and it adds extraneous divs to our schematics. We could move the logic inside the article element instead:

function Post({ id }) {
  const containerRef = React.useRef(null);
  const { data, loading } = useResource(`post/${id}`);

  useEffect(() => {
    const node = containerRef.current;
    console.log(node);
  }, []);

  return (
    <article ref={containerRef}>
      { loading ? <Loading /> : (
        <>
          <h1>{data.title}</h1>
          <p>{data.body}</p>
        </>
      ) }
    </div>
  );
}

It works! But what if we wanted to get the contents of the element? We can use innerHTML on the ref to try and get the contents:

function Post({ id }) {
  const containerRef = React.useRef(null);
  const { data, loading } = useResource(`post/${id}`);

  useEffect(() => {
    const node = containerRef.current;
    console.log(node.innerHTML); // => [element of Loading]
  }, []);

  return (
    <article ref={containerRef}>
      { loading ? <Loading /> : (
        <>
          <h1>{data.title}</h1>
          <p>{data.body}</p>
        </>
      ) }
    </div>
  );
}

This will give us the element that the Loading component renders. We can't get the contents of the article without updating our component, either forcefully or removing dependencies from the hook.

Is there a way we can solve this? Absolutely!

Lift me up

Since we're waiting for the data to be loaded before rendering the article, we can split that logic out to its own component. There's a well-known pattern called the Container pattern that can help us with this kind of separation.

Containers can be whatever you'd like. Often times they are entire screens or pages. Other times they're just concerned about preparing the data and return a presentational component. The important thing is just that we can separate the concerns between handling state or data and declaring our UI. Let's stick with the latter for the sake of simplicity.

We declare a Container component and move the logic for data fetching and handling loading state into it:

function PostContainer({ id }) {
  const { data, loading } = useResource(`post/${id}`);

  if (loading) {
    return <Loading />
  }

  return <Post post={data} />;
}

We also change the props of the Post component to just accept the data through a post prop. This way, we can render the data for the post:

function Post({ post }) {
  const containerRef = React.useRef(null);

  useEffect(() => {
    const node = containerRef.current;
    console.log(node);
  }, []);

  return (
    <article ref={containerRef}>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

Now it works again! And we get our loading state. Our code also looks a lot nicer for handling the conditions.

We could handle other states here as well and the Post component will always be able to get the ref as it is rendered when everything is ready. With this it gives us a clear separation of data and UI, as well as solves our problem with the ref.

Conclusion

This way of splitting up components, makes it quite simple to think about and helps to avoid some pitfalls that you may run into when working with conditionals.

The Container pattern also applies to class components, since they have the similar constraints for rendering components and handling refs when using lifecycle methods.

Top comments (2)

Collapse
 
krishnath12 profile image
Krishnath12

I am facing the same issue. I read through your post but still couldn't understand how it can solve the issue ( null ref ). In the case of your example, i.e. Post component which is wrapped in container component ( PostContainer ), this wrapped container has to be composed in a parent component where that ref will be of use. So the issue remains as is.

Collapse
 
muhammadjamaludin profile image
محمد جمال الدين • Edited

Thanks for this. It saved my day. I used the container pattern you mentioned & it worked perfectly.

I am curious though to know what you think about the callback ref pattern (see the url below) that React docs seems to suggest as the official way of handling this use case?

reactjs.org/docs/hooks-faq.html#ho...