-- online Search!

Accessing DOM Elements in React

by kirupa   |   filed under Learning React

There will be times when you want to get your hands on an HTML element directly. In our React-colored world, where JSX describes everything we want on the screen, why would you ever want to deal with the actual DOM? As you'll find out (if you haven't already), there are things that are only possible by talking to an element itself. You can't tell an element to take focus by changing a prop. You can't ask JSX how wide something is. For that, you need the element, and React gives us a feature called refs to get it.

To highlight one such situation, take a look at the Colorizer example (view in separate window):

The Colorizer colorizes the (currently) white square with whatever color you provide it. To see it in action, enter a color value inside the text field and click/tap on the go button. If you don't have any idea of what color to enter, yellow is a good one! Once you have provided a color and submitted it, the white square will turn that color:

The Colorizer showing a yellow square after yellow was submitted

That the square changes color for any valid color value you submit is pretty awesome, but it isn't what I want you to focus on. Instead, pay attention to the text field and the button after you submit a value. Notice that the button has focus, and the color value you just submitted is still displayed inside the text field. If you want to enter another color value, you need to explicitly click back into the text field and clear what is there. That is a little annoying.

Wouldn't it be great if the text field cleared itself and took focus back right after you submit a color? If we submitted purple, we would want to see the following:

The Colorizer showing a purple square with an empty, focused text field

The value of purple is cleared, and the focus is back in the text field. Now we can type another color and submit it without jumping between the text field and the button. Isn't that much nicer?

Giving an element focus is not something JSX can describe. It's an action, and it lives on the element itself as a focus() method. This is a job for a ref. In the following sections, we'll build the Colorizer, use useRef to get the behavior we want, look at what's new in React 19 when it comes to refs, and learn about portals, which let us render JSX somewhere else on the page.

Onwards!

Learning React by Kirupa Chinnathambi

OMG! A React Book Written by Kirupa?!!

To kick your React skills up a few notches, everything you see here and more (with all its casual clarity!) is available in both paperback and digital editions.

BUY ON AMAZON

The Colorizer Example

Let's start by building the Colorizer with only the things we already know. Create a new Vite project like we did in Setting Up Your React Dev Environment, and replace the contents of src/App.jsx with the following. We use two pieces of state, one to remember what has been typed in (color) and one for the color of the square (bgColor):

import { useState } from "react";
import "./App.css";

function Colorizer() {
  const [color, setColor] = useState("");
  const [bgColor, setBgColor] = useState("white");

  function handleSubmit(e) {
    e.preventDefault();
    setBgColor(color);
  }

  return (
    <div className="colorArea">
      <div style={{ backgroundColor: bgColor }} className="colorSquare"></div>

      <form onSubmit={handleSubmit}>
        <input
          onChange={(e) => setColor(e.target.value)}
          placeholder="Enter a color value"
        />
        <button type="submit">go</button>
      </form>
    </div>
  );
}

export default Colorizer;

Next, replace the contents of src/App.css with the styles for our square and form. Also clear out everything inside src/index.css so the Vite template's default styles don't get in the way:

body {
  padding: 50px;
  background-color: #FFF;
}

.colorSquare {
  box-shadow: 0px 0px 25px 0px #333;
  width: 242px;
  height: 242px;
  margin-bottom: 15px;
}

.colorArea input {
  padding: 10px;
  font-size: 16px;
  border: 2px solid #CCC;
}

.colorArea button {
  padding: 10px;
  font-size: 16px;
  margin: 10px;
  background-color: #666;
  color: #FFF;
  border: 2px solid #666;
}

.colorArea button:hover {
  background-color: #111;
  border-color: #111;
  cursor: pointer;
}

Take a few moments to look through the code and see how it maps to our example. There shouldn't be anything surprising here. When the form is submitted, we copy the typed-in color into bgColor, and React redraws the square. Once you are comfortable with this code, it is time to meet refs.

Meet useRef

As you know very well by now, the JSX we write is a description of what the DOM should look like. It isn't the real thing, despite looking a lot like HTML. To bridge the gap between our JSX and the real DOM elements, React gives us the useRef hook. Here is our Colorizer again with a ref added to it. The changes are highlighted:

import { useState, useRef } from "react";
import "./App.css";

function Colorizer() {
  const [color, setColor] = useState("");
  const [bgColor, setBgColor] = useState("white");
  const inputRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    setBgColor(color);

    inputRef.current.value = "";
    inputRef.current.focus();
  }

  return (
    <div className="colorArea">
      <div style={{ backgroundColor: bgColor }} className="colorSquare"></div>

      <form onSubmit={handleSubmit}>
        <input
          ref={inputRef}
          onChange={(e) => setColor(e.target.value)}
          placeholder="Enter a color value"
        />
        <button type="submit">go</button>
      </form>
    </div>
  );
}

export default Colorizer;

Preview this and submit a color. The square changes, the text field clears itself, and the cursor is back inside it, ready for your next color. Let's see how this works.

On line 7, we call useRef(null). What we get back is a plain object that looks like { current: null }. We store it in a variable called inputRef. A ref is a box that React keeps for us, and it holds on to the same box across renders. Nothing is in it yet, so current starts out as null.

On line 23, we hand that box to our input element through its ref attribute. After React has created the real input element and put it on the page, it sets inputRef.current to point at it. If the input is later removed, React sets it back to null.

Finally, on lines 13 and 14, inside our submit handler, we use inputRef.current just like any other DOM element. We set its value to an empty string to clear it, and we call its focus() method to give it focus.

Notice that this happens inside an event handler. That's the right place to read or use a ref. The element doesn't exist until React has finished rendering, which is why current is null while your component function is running for the first time. Event handlers and effects run after the element exists, so current is ready to use.

Refs Can Hold Other Things Too

Although DOM elements are the most common use, a ref can hold on to any value that you want to keep around between renders. Remember that we said useRef gives you a box that stays the same across renders? Changing current doesn't cause your component to render again, unlike a state update. That makes a ref a good place for things like a timer ID that you need later, but that has no effect on what's displayed.

Passing a Ref to Your Own Component

The input in our example is a built-in HTML element, but you will often want to put a ref on a component that you wrote. Let's move our text field into its own component called ColorInput:

function ColorInput({ ref, onChange }) {
  return (
    <input
      ref={ref}
      onChange={onChange}
      placeholder="Enter a color value"
    />
  );
}

Look at what we are doing here. ref is just another prop. We receive it in the parameters (along with onChange), and then pass it on to the real input element. In our Colorizer component, we use it like we would any other prop:

<form onSubmit={handleSubmit}>
  <ColorInput ref={inputRef} onChange={(e) => setColor(e.target.value)} />
  <button type="submit">go</button>
</form>

Everything works exactly the same as before. inputRef.current still points to the real input element, even though it sits inside another component.

Note: What About forwardRef?

If you've seen older React code, you may have seen a function called forwardRef wrapped around a component like this one. Before React 19, a ref wasn't a normal prop. React hid it from your component, and forwardRef was the workaround to get it back. In React 19, function components receive ref as a regular prop, so you don't need forwardRef anymore. The old way still works for now, but the React team has said it will be deprecated in a future version, so new code shouldn't use it.

 

Ref Callbacks and Clean-up

So far, we've handed ref a box created by useRef. There is a second way: you can give it a function. React calls that function with the DOM element when the element is added to the page. This is known as a ref callback. Starting with React 19, your ref callback can also return a clean-up function, and React will call it when the element is removed. That should sound familiar from our useEffect adventures:

<div
  style={{ backgroundColor: bgColor }}
  className="colorSquare"
  ref={(square) => {
    console.log("Square attached:", square.className);

    return () => {
      console.log("Square detached");
    };
  }}
></div>

If you put this on the square in the Colorizer and then show and hide the square, you'll see the attach and detach messages appear in the console as the element comes and goes. This is handy when you need to set something up on an element, such as an observer or an event listener from a third-party library, and want to tear it down at the right time. In development, Strict Mode runs this attach, detach, and attach sequence once at the beginning, for the same reason it does so for effects.

There are two details to watch for:

For most of what you do, useRef will be all you need. Ref callbacks are there for the cases where you need more control.

Using Portals

There is one more DOM-related trick that you need to know about. So far, everything our components render ends up inside the root element that React is attached to. A component's output sits inside its parent's output, which sits inside its parent's output, and so on. Sometimes that's a problem. A modal dialog, a tooltip, or a dropdown menu may need to appear on top of everything else, and a parent with an overflow: hidden or a particular stacking order can clip it or hide it.

A portal lets a component render its JSX into a different spot in the DOM while still being part of your React tree. You create one with a function called createPortal, which you import from react-dom. It takes two things: the JSX to render, and the DOM element to render it into.

Let's see this in action. Open your Vite project's index.html file and add the following h1 element just above the div with the root id. It sits outside of the place where React renders our app:

<body>
  <h1 id="colorHeading">Colorizer</h1>
  <div id="root"></div>
  <script type="module" src="/src/main.jsx"></script>
</body>

Now we'll create a small component that uses a portal to add the current color to the end of that heading:

import { createPortal } from "react-dom";

function ColorLabel({ color }) {
  return createPortal(
    <span>: {color}</span>,
    document.getElementById("colorHeading")
  );
}

Our ColorLabel doesn't return JSX in the usual way. It returns the result of createPortal, which says: "render this span into the colorHeading element". To use it, add <ColorLabel color={bgColor} /> anywhere inside the JSX of our Colorizer component. When you submit a color, the heading changes to say "Colorizer: yellow":

The page heading reads Colorizer: yellow above a yellow square and a text field

Even though our ColorLabel sits within the Colorizer's JSX, its output shows up in the heading outside of it. From React's point of view, though, nothing about the component tree has changed. The portal's content can still read props and state from its parent, and an event inside the portal still bubbles up to the React parents (not the DOM parents) of the component that created it.

When to Avoid Refs

Refs are what the React docs call an escape hatch. They are a way to step outside of React for the things that React can't do for you. It's easy to get carried away with them, so here are some guidelines:

Our Colorizer actually has the first problem. Look at what we did in the submit handler: we cleared the input by setting inputRef.current.value = "", but our color state still holds the old text. State says purple, and the text field says nothing. If you click the go button again with an empty text field, the square will turn purple again instead of doing nothing, because the state was never told about our little DOM trick.

The fix is to let state be the source of truth by making the input a controlled input (we'll look at them in Forms in React). We give the input a value that comes from state, and we clear it by changing the state. We still use the ref for what only it can do, which is giving the field focus. Here is the full Colorizer with everything we've talked about (the ref as a prop, the portal, and the controlled input):

Optional: The Full Code

import { useState, useRef } from "react";
import { createPortal } from "react-dom";
import "./App.css";

function ColorLabel({ color }) {
  return createPortal(
    <span>: {color}</span>,
    document.getElementById("colorHeading")
  );
}

function ColorInput({ ref, value, onChange }) {
  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
      placeholder="Enter a color value"
    />
  );
}

function Colorizer() {
  const [color, setColor] = useState("");
  const [bgColor, setBgColor] = useState("white");
  const inputRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    setBgColor(color);
    setColor("");
    inputRef.current.focus();
  }

  return (
    <div className="colorArea">
      <ColorLabel color={bgColor} />
      <div style={{ backgroundColor: bgColor }} className="colorSquare"></div>

      <form onSubmit={handleSubmit}>
        <ColorInput
          ref={inputRef}
          value={color}
          onChange={(e) => setColor(e.target.value)}
        />
        <button type="submit">go</button>
      </form>
    </div>
  );
}

export default Colorizer;

 

Notice that the only thing left for the ref to do is call focus(). Clearing the field and updating the square are all done through state, so everything stays in sync.

Conclusion

Most of the time, everything you want to do will be within arm's reach of the JSX you are writing. There will be times when you need to break out of the box React puts you in. To help with that, we looked at two features, refs and portals. Refs, created with useRef, give you access to the real DOM elements behind your JSX. In React 19, you can pass a ref to your own components just like any other prop, with no forwardRef needed, and a ref callback can return a clean-up function. Portals, created with createPortal, let a component render its output into a completely different part of the page.

Use these tools when you need them, but reach for state and props first. In the next tutorial, we'll look at forms in React, where controlled inputs like the one we just used take center stage.

Next tutorial: Forms in React

Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.

Your support keeps this site going! 😇

Kirupa's signature!

Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.

The KIRUPA Newsletter

Thought provoking content that lives at the intersection of design 🎨, development 🤖, and business 💰 - delivered weekly to over a bazillion subscribers!

SUBSCRIBE NOW

Creating engaging and entertaining content for designers and developers since 1998.

Follow:

Popular

Loose Ends

:: Copyright KIRUPA 2026 //--