Blog

Part 14 · useRef in React: Reaching the Page and Remembering Without Re-rendering

useRef keeps a value between renders without causing a render, and it lets your code reach a real tag on the page. Learn both jobs, when a ref is null, and the common mistakes.

In Part 4 you saw two problems with a normal variable. Changing it doesn’t make React render again. And it starts over on every render. useState fixed both problems.

Sometimes you want only half of that. You want React to keep a value between renders. But you don’t want a new render each time the value changes. That is the first job of useRef.

useRef has a second job. It lets your code reach a real tag on the page, like a text box. Then your code can use it. For example, it can make the box ready for typing.

This part covers both jobs. A few examples use Effects, from Part 11 and Part 12.

Try this first

Read this code. Don’t press Run yet.

import { useRef } from 'react'

export default function App() {
  const clicks = useRef(0)
  console.log('App renders')

  function handleClick() {
    clicks.current = clicks.current + 1
    console.log('clicks.current is', clicks.current)
  }

  return <button onClick={handleClick}>Click me</button>
}
App renders
App renders
clicks.current is 1
clicks.current is 2
clicks.current is 3

Make a guess. You click the button three times. How many times will the Console say “App renders”?

Press Run, click three times, and look at the Console.

“App renders” appears twice, and only at the start. That is Strict Mode, from Part 2: in development, React calls each component twice when it renders. The clicks add no more “App renders” lines. But the count still goes up: 1, 2, 3.

So the ref remembered the number. And changing it did not make App render again.

What a ref is

useRef(0) gives back a plain object with one property, current. Its first value is the value you passed in:

{ current: 0 }

We checked with React 19.3: the object’s only key is current.

You read the value with clicks.current. You change it by giving clicks.current a new value, like any object property. There is no set function.

React gives back the same object on every render. That’s how the value survives. We checked: over 6 calls of a component, with Strict Mode on, useRef gave back one object every time. React’s docs say Strict Mode may make a second ref object and throw it away. Either way, the one you get stays the same.

A ref also changes at once. In Part 4, state was a snapshot: after setCount(1), count still held the old value until the next render. A ref is not like that. We set ref.current = 5 in a click handler and read it on the next line. It was already 5.

A ref survives a re-render, a normal variable doesn’t

Part 4 showed a normal variable starting over. Let’s put a normal variable and a ref side by side.

import { useRef, useState } from 'react'

export default function App() {
  let plain = 0
  const ref = useRef(0)
  const [renders, setRenders] = useState(0)

  function handleAdd() {
    plain = plain + 1
    ref.current = ref.current + 1
    console.log('plain:', plain, 'ref:', ref.current)
  }

  return (
    <div>
      <button onClick={handleAdd}>Add one</button>
      <button onClick={() => setRenders(renders + 1)}>Render again</button>
    </div>
  )
}
plain: 1 ref: 1
plain: 2 ref: 2
plain: 1 ref: 3

Press Run. Click “Add one” twice. Then click “Render again”, which changes state, so App renders again. Then click “Add one” once more.

After the re-render, plain went back to 0, so it logged 1. The line let plain = 0 ran again. But the ref kept counting: 3.

Changing a ref doesn’t change the page

Here is the other half. What if you show a ref’s value on the page?

This example breaks a rule on purpose: it reads ref.current while rendering. We’ll see the rule soon. For now, watch what happens.

import { useRef, useState } from 'react'

export default function App() {
  const ref = useRef(0)
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Ref: {ref.current}, state: {count}</p>
      <button onClick={() => { ref.current = ref.current + 1 }}>Add to ref</button>
      <button onClick={() => setCount(count + 1)}>Add to state</button>
    </div>
  )
}

Run it. Click “Add to ref” three times. The page still says “Ref: 0”.

Now click “Add to state” once. The page says “Ref: 3, state: 1”. The ref was 3 all along. The page only caught up because state changed, and React rendered again for that reason.

React’s docs explain why. They say “React is not aware of when you change it”. A ref is a plain JavaScript object, so React can’t see it change. Setting state tells React to render. Setting a ref tells React nothing.

1. The page shows Count: 0. React keeps count = 0. 2. You click. The handler calls setCount(1). 3. React calls App again. This is a re-render. 4. React changes the page. It shows Count: 1. onClick={() => setCount(count + 1)} what React keeps for App count = 0 the page Count: 0 React calls App() again

Change state, or change a ref. Choose one, then press play or step through it.

Here are both cases in words.

Change state:

  1. The page shows “Count: 0”. React keeps count = 0.
  2. You click. The handler calls setCount(1).
  3. React calls App again. This is a re-render.
  4. React changes the page. It shows “Count: 1”.

Change a ref:

  1. The page shows “Count: 0”. The ref holds 0.
  2. You click. The handler sets ref.current to 1.
  3. React is not told, so it does not call App again.
  4. The page still shows “Count: 0”. The ref holds 1.

Job 1: remembering an interval’s id

A real use for the first job is a stopwatch: a clock that counts seconds, with Start and Stop buttons.

setInterval is a browser function. It runs a function again and again, with a wait between the runs. This is called an interval. setInterval gives back a number that names this interval: the interval’s id. To stop the interval, you pass that id to clearInterval.

So the stopwatch must remember the id between the click on Start and the click on Stop. The id never shows on the page. That makes it a good fit for a ref.

import { useRef, useState } from 'react'

export default function App() {
  const [seconds, setSeconds] = useState(0)
  const intervalRef = useRef<number | null>(null)

  function handleStart() {
    if (intervalRef.current !== null) return
    intervalRef.current = window.setInterval(() => {
      setSeconds(s => s + 1)
    }, 1000)
  }

  function handleStop() {
    if (intervalRef.current === null) return
    window.clearInterval(intervalRef.current)
    intervalRef.current = null
  }

  return (
    <div>
      <p>Seconds: {seconds}</p>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
    </div>
  )
}

Press Run, then Start. After two seconds, press Stop. The number stops.

Look at what each piece does:

  • seconds is state, because it shows on the page.
  • intervalRef holds the id, because only the click handlers need it.
  • useRef<number | null>(null) tells TypeScript the ref holds a number or null. null here means “not running”.
  • if (intervalRef.current !== null) return stops a second interval from starting. We pressed Start twice, and setInterval was called only once.

The interval sets seconds every second, so App renders again and again. The ref keeps the id through all those renders.

One more thing. If the stopwatch leaves the page while it runs, the interval keeps going. We checked: after we removed the app, the interval still ran every second. Part 12 shows how to stop it with an Effect’s cleanup.

Try it with a normal variable

What if the id lives in a normal variable instead?

import { useState } from 'react'

export default function App() {
  const [seconds, setSeconds] = useState(0)
  let intervalId: number | null = null

  function handleStart() {
    intervalId = window.setInterval(() => {
      setSeconds(s => s + 1)
    }, 1000)
  }

  function handleStop() {
    if (intervalId === null) return
    window.clearInterval(intervalId)
    intervalId = null
  }

  return (
    <div>
      <p>Seconds: {seconds}</p>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
    </div>
  )
}

Run it. Press Start, wait at least two seconds, then press Stop. The clock doesn’t stop.

Here is why. The first time the interval runs, it changes seconds, so App renders again. That render runs let intervalId = null again, and makes a new handleStop. The new handleStop sees null, so it returns without stopping anything. We pressed Stop after 2.5 seconds. 2.2 seconds later, the page said “Seconds: 4”, and the interval was still running.

Press Run again to stop it. That loads the example fresh.

Don’t read or write ref.current while rendering

React’s docs give a clear rule: “Do not write or read ref.current during rendering, except for initialization.”

While rendering means in the component’s body, as React calls your function. Event handlers and Effects are fine. They run later.

Why the rule? Part 2 said a component should give the same result every time it runs with the same inputs. A ref is not one of those inputs. React doesn’t know when it changes. So code that reads it during rendering can show old or surprising values.

Here is a common example: counting renders by adding one in the component’s body.

import { useRef, useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  const renders = useRef(0)
  renders.current = renders.current + 1

  return (
    <div>
      <p>Count: {count}</p>
      <p>Renders: {renders.current}</p>
      <button onClick={() => setCount(count + 1)}>Add one</button>
    </div>
  )
}

Run it. Right after loading, it says “Renders: 2”. Each click adds 2.

Strict Mode called App twice each time, and both calls added one. With Strict Mode off, we got 1, 2, 3, 4. So the same code shows different numbers in development and in the finished app. You can’t count on what it shows.

To see how often a component renders, use console.log in its body, as this series does. Don’t put the number on the page.

Keeping the previous value

Another common idea: keep the previous value in a ref, and show it. This version updates the ref in an Effect, after each change of count.

import { useEffect, useRef, useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  const [color, setColor] = useState('red')
  const previous = useRef(0)

  useEffect(() => {
    previous.current = count
  }, [count])

  return (
    <div>
      <p>Now: {count}, before: {previous.current}</p>
      <p>Color: {color}</p>
      <button onClick={() => setCount(count + 1)}>Add one</button>
      <button onClick={() => setColor(color === 'red' ? 'blue' : 'red')}>Change color</button>
    </div>
  )
}

Run it and click “Add one” twice. It says “Now: 2, before: 1”. That looks right.

Now click “Change color”. It says “Now: 2, before: 2”. That’s wrong. The count didn’t change.

The Effect had already copied 2 into the ref. The color change caused a new render, and that render read the ref. So the page showed whatever the ref held at that moment.

If the page shows it, keep it in state. Save the old value in the same click:

import { useState } from 'react'

export default function App() {
  const [count, setCount] = useState(0)
  const [previous, setPrevious] = useState(0)
  const [color, setColor] = useState('red')

  function handleAdd() {
    setPrevious(count)
    setCount(count + 1)
  }

  return (
    <div>
      <p>Now: {count}, before: {previous}</p>
      <p>Color: {color}</p>
      <button onClick={handleAdd}>Add one</button>
      <button onClick={() => setColor(color === 'red' ? 'blue' : 'red')}>Change color</button>
    </div>
  )
}

Now “before” stays 1 after the color change. We checked both versions with Strict Mode on and off.

The one exception: filling a ref once

The rule says “except for initialization”. Initialization means giving something its first value. The docs allow this shape:

import { useRef } from 'react'

function Boxes() {
  const mapRef = useRef<Map<number, HTMLInputElement> | null>(null)
  if (mapRef.current === null) {
    mapRef.current = new Map()
  }
  return null
}

This writes the ref during rendering, but only on the first render. So the result is always the same. Use it when the first value is costly to make. For a small value like 0, write useRef(0).

Ref or state?

Both keep a value between renders. Here is how they differ.

Ref State
You make it with useRef(0) useState(0)
It gives you an object, { current: 0 } the value and a set function
Change it with ref.current = 1 setCount(1)
Does a change cause a render? No Yes
When does the new value show up? At once In the next render
Read it while rendering? No (except to fill it once) Yes
Use it for interval ids, DOM nodes, and other values the page doesn’t show anything the page shows

The short rule: if the page shows it, use state. If only your handlers and Effects need it, a ref may do.

Most values belong in state. React’s docs call refs an “escape hatch”. An escape hatch is a small door for getting out in rare cases. Here it means a way to step outside React’s normal flow. You won’t need it often.

An everyday example

Think of a game. The score board is state. When a team scores, someone changes the board, and everyone sees the new score.

A ref is a note in the coach’s pocket: “the timer we started is number 7”. The coach can read it or change it at any time. Nobody else sees it, and changing it doesn’t change the board.

The exact version

The picture breaks in one place. You can show a ref’s value on the board, by reading it while rendering. It just won’t stay right. The board only changes when something else makes React render again. That’s why the rule says not to read it there.

Job 2: reaching a tag on the page

The browser keeps an object for every tag on the page. These objects together are called the DOM (Document Object Model). Each one of these objects is called a DOM node. A DOM node has methods that React doesn’t offer, like focus().

An element has focus when it’s the one that gets your key presses. Part 6 explained it. Here is how to move the focus into a text box when you click a button:

import { useRef, useState } from 'react'

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null)
  const [focused, setFocused] = useState(false)

  function handleClick() {
    if (inputRef.current !== null) {
      inputRef.current.focus()
    }
  }

  return (
    <div>
      <input
        ref={inputRef}
        aria-label="Your name"
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
      />
      <button onClick={handleClick}>Focus the box</button>
      <p>The box {focused ? 'has' : 'does not have'} focus.</p>
    </div>
  )
}

Run it and press the button. The box gets the focus, ready for typing. The text says “The box has focus.”

Three steps make this work:

  1. useRef<HTMLInputElement>(null) makes a ref that starts empty.
  2. ref={inputRef} on the <input> asks React to put this input’s DOM node into inputRef.current.
  3. The click handler reads inputRef.current and calls focus() on it.

aria-label gives the box a name that screen readers say out loud. Part 46 covers it.

When is inputRef.current equal to null?

The ref starts as null. React fills it later. Let’s follow the first render.

1. First render: React calls App. The ref holds null. 2. App returns JSX. The ref is only passed along. 3. React puts a real <input> on the page. 4. Then React sets inputRef.current to that input. 5. Later, a click handler can use it. const inputRef = useRef<HTMLInputElement>(null) // current: null <input ref={inputRef} /> the page now has <input> inputRef.current // the <input> on the page inputRef.current.focus()

When a DOM ref gets its value. Press play, or step through it.

  1. React calls App. The ref holds null.
  2. App returns JSX. ref={inputRef} only passes the ref along. Nothing is on the page yet.
  3. React puts a real <input> on the page. This step is the commit, from Part 11.
  4. Then React sets inputRef.current to that input.
  5. Later, a click handler can use it.

We logged the ref during the first render: it was null. With Strict Mode on, it was null in both calls. After the commit, React set it to the input.

When the input leaves the page, React sets the ref back to null. Try it:

import { useRef, useState } from 'react'

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null)
  const [show, setShow] = useState(true)

  function handleCheck() {
    console.log('inputRef.current is', inputRef.current === null ? 'null' : 'an input')
  }

  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Hide' : 'Show'} the box</button>
      <button onClick={handleCheck}>Check the ref</button>
      {show && <input ref={inputRef} aria-label="Name" />}
    </div>
  )
}
inputRef.current is an input
inputRef.current is null
inputRef.current is an input

Click “Check the ref”, “Hide the box”, “Check the ref”, “Show the box” and “Check the ref”. The ref is an input, then null, then an input again.

In Strict Mode, React also does an extra check each time the input is added. It sets the ref to the input, then to null, then to the input again. We saw this in our test. It happens only in development.

So the ref can be null in three cases:

  • during the first render, before the input is on the page;
  • after the input leaves the page;
  • when the tag with ref={inputRef} is not rendered at all.

That is why the click handler checks for null first.

TypeScript and the check for null

useRef<HTMLInputElement>(null) tells TypeScript two things. The ref will hold an <input>‘s DOM node. And it starts as null. HTMLInputElement is the browser’s type for an <input>. Other tags have their own types, like HTMLDivElement and HTMLButtonElement.

So inputRef.current has the type HTMLInputElement | null. If you call focus() without a check, TypeScript in your editor stops you:

import { useRef } from 'react'

function NameForm() {
  const inputRef = useRef<HTMLInputElement>(null)

  function handleClick() {
    inputRef.current.focus()
  }

  return <input ref={inputRef} onClick={handleClick} />
}

The error is 'inputRef.current' is possibly 'null'. Check first, with if (inputRef.current !== null). Or use ?. from Part 7: inputRef.current?.focus() calls focus() only when the ref is not null.

With React 19’s TypeScript types, useRef needs a first value. Older code sometimes wrote useRef<number>() with nothing inside. The playground would still run it, but TypeScript gives an error:

import { useRef } from 'react'

function Timer() {
  const intervalRef = useRef<number>()
  return null
}

Pass null and allow it in the type: useRef<number | null>(null), as the stopwatch did.

Scrolling to a tag

To scroll is to move the page up or down, to see another part of it. A DOM node can scroll the page to itself, with scrollIntoView(). The page moves until that tag can be seen.

import { useRef } from 'react'

export default function App() {
  const endRef = useRef<HTMLParagraphElement>(null)
  const lines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`)

  return (
    <div>
      <button onClick={() => endRef.current?.scrollIntoView({ behavior: 'smooth' })}>
        Go to the end
      </button>
      {lines.map(line => <p key={line}>{line}</p>)}
      <p ref={endRef}>The end.</p>
    </div>
  )
}

Run it and press the button. The result box scrolls down until “The end.” can be seen. behavior: 'smooth' makes it slide instead of jump.

HTMLParagraphElement is the type for a <p>. Array.from({ length: 50 }, ...) makes an array of 50 lines, so there is something to scroll past.

Measuring a tag

getBoundingClientRect() tells you where a tag is and how big it is. The numbers depend on your screen.

import { useRef, useState } from 'react'

export default function App() {
  const boxRef = useRef<HTMLDivElement>(null)
  const [size, setSize] = useState('not measured yet')

  function handleMeasure() {
    if (boxRef.current === null) return
    const rect = boxRef.current.getBoundingClientRect()
    setSize(`${Math.round(rect.width)} by ${Math.round(rect.height)} pixels`)
  }

  return (
    <div>
      <div ref={boxRef} style={{ width: '50%', padding: 20, background: 'lightblue' }}>
        Measure me
      </div>
      <button onClick={handleMeasure}>Measure the box</button>
      <p>Size: {size}</p>
    </div>
  )
}

Run it and press the button. The size goes into state, because the page shows it. The ref is only used to reach the box.

Focusing, scrolling and measuring are safe. They don’t change the page. Don’t use a ref to add or remove tags that React put on the page. React’s docs warn that this can make the page look wrong, or even crash. Change state, and let React change the page.

A ref to your own component

So far, ref went on a built-in tag like <input>. What about your own component, like <NameBox />?

In React 19, ref is a normal prop for a function component. Part 3 checked this. The component takes ref from its props and passes it on to a tag inside it:

import { useRef, useState, type Ref } from 'react'

function NameBox({ ref, onFocus }: { ref?: Ref<HTMLInputElement>; onFocus: () => void }) {
  return <input ref={ref} aria-label="Name" onFocus={onFocus} />
}

export default function App() {
  const nameRef = useRef<HTMLInputElement>(null)
  const [message, setMessage] = useState('Not focused yet.')

  return (
    <div>
      <NameBox ref={nameRef} onFocus={() => setMessage('The name box has focus.')} />
      <button onClick={() => nameRef.current?.focus()}>Focus the name box</button>
      <p>{message}</p>
    </div>
  )
}

Ref<HTMLInputElement> is React’s type for “a ref that can hold an input”. The ? makes the prop optional.

What if NameBox doesn’t pass ref on? Then nothing fills the ref. We tried it: ref.current stayed null, and React 19.3 printed no warning. So if a ref to your component is always null, check that the component passes it on.

forwardRef: what older code does

Before React 19, a function component did not get ref in its props. You had to wrap it in forwardRef. You’ll still see this in older code:

import { forwardRef } from 'react'

const NameBox = forwardRef<HTMLInputElement, { onFocus: () => void }>(function NameBox(props, ref) {
  return <input ref={ref} aria-label="Name" onFocus={props.onFocus} />
})

It still works in React 19.3. We tried it, and it printed no warning. But React’s docs say that in React 19, forwardRef “is no longer necessary”. They also say it “will be deprecated in a future release”. Deprecated means “still works, but planned for removal”. Write new components with ref as a prop.

useImperativeHandle: share less than the whole tag

When you pass ref on to the <input>, the parent gets the whole DOM node. It could change the input’s style, its value, anything.

Sometimes you want to share less. useImperativeHandle lets the child choose what the parent’s ref gets. Here, the parent only gets a focus method:

import { useImperativeHandle, useRef, useState, type Ref } from 'react'

type NameBoxHandle = { focus: () => void }

function NameBox({ ref, onFocus }: { ref?: Ref<NameBoxHandle>; onFocus: () => void }) {
  const inputRef = useRef<HTMLInputElement>(null)

  useImperativeHandle(ref, () => ({
    focus() {
      inputRef.current?.focus()
    },
  }), [])

  return <input ref={inputRef} aria-label="Name" onFocus={onFocus} />
}

export default function App() {
  const nameRef = useRef<NameBoxHandle>(null)
  const [message, setMessage] = useState('Not focused yet.')

  function handleCheck() {
    console.log('The parent can use:', Object.keys(nameRef.current ?? {}).join(', '))
  }

  return (
    <div>
      <NameBox ref={nameRef} onFocus={() => setMessage('The name box has focus.')} />
      <button onClick={() => nameRef.current?.focus()}>Focus the name box</button>
      <button onClick={handleCheck}>What can the parent use?</button>
      <p>{message}</p>
    </div>
  )
}
The parent can use: focus

The child keeps its own ref to the real input. It gives the parent an object with only focus(). We checked: the parent’s nameRef.current was not a DOM node, and its style was undefined.

The [] at the end works like an Effect’s dependency array from Part 11. React makes the object again only when a value in the list changes. You’ll rarely need this hook. Most of the time, passing ref on is enough.

Ref callbacks: a function instead of an object

ref can also take a function. React calls it with the DOM node when the tag goes on the page. This is called a ref callback. A callback is a function you give to someone else, for them to call later.

In React 19, a ref callback can return a cleanup function. React calls the cleanup when the tag leaves the page.

import { useState } from 'react'

export default function App() {
  const [show, setShow] = useState(true)

  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Hide' : 'Show'}</button>
      {show && (
        <input
          aria-label="Name"
          ref={node => {
            console.log('React gave us the input:', node !== null)
            return () => {
              console.log('Cleanup ran')
            }
          }}
        />
      )}
    </div>
  )
}
React gave us the input: true
Cleanup ran
React gave us the input: true
Cleanup ran
React gave us the input: true
Cleanup ran
React gave us the input: true
Cleanup ran

Run it, then press “Hide”, “Show” and “Hide” again. Here is where each line comes from:

  • The first three lines are the first load. Strict Mode adds one extra setup and cleanup. It tests that your cleanup cleans up after your setup. With Strict Mode off, we saw only one line.
  • “Hide” gives one “Cleanup ran”.
  • “Show” adds the input again, and Strict Mode tests it again: three more lines.
  • The last “Hide” gives one more “Cleanup ran”.

React’s docs describe this: “React will run one extra development-only setup+cleanup cycle before the first real setup.”

What if the callback returns nothing? Then React calls it a second time, with null, when the tag leaves. We checked: hiding the box called it with null. The docs say this older behavior “will be removed in a future version”. So in new code, return a cleanup function.

A ref callback may now return a cleanup function. So React 19’s TypeScript types refuse a callback that returns anything else. This short arrow function returns the value of saved = node by accident:

function NameBox() {
  let saved: HTMLInputElement | null = null
  return <input ref={node => (saved = node)} />
}

TypeScript says the function is not assignable to type 'Ref<HTMLInputElement> | undefined'. Use curly braces, so the function returns nothing: ref={node => { saved = node }}.

There is one more thing to know. This callback is a new function on every render. So each time App renders again, React runs the old cleanup and the new callback. We measured one “Cleanup ran” and one new call for each re-render. That is fine when both are small and quick.

A ref for each item in a list

Hooks can’t be called inside a loop. That is one of the rules of hooks from Part 4. So you can’t call useRef once per item. Instead, keep one ref that holds a Map. A Map is a JavaScript object that stores values under keys. Here the key is the person’s id, and the value is that person’s input.

A ref callback adds each input to the Map. Its cleanup removes it.

import { useRef, useState } from 'react'

type Person = { id: number; name: string }

export default function App() {
  const [people, setPeople] = useState<Person[]>([
    { id: 1, name: 'Ana' },
    { id: 2, name: 'Ben' },
    { id: 3, name: 'Cara' },
  ])
  const [focusedName, setFocusedName] = useState('nobody')
  const boxesRef = useRef(new Map<number, HTMLInputElement>())

  function focusBox(id: number) {
    boxesRef.current.get(id)?.focus()
  }

  return (
    <div>
      {people.map(person => (
        <input
          key={person.id}
          aria-label={person.name}
          onFocus={() => setFocusedName(person.name)}
          ref={node => {
            if (node !== null) boxesRef.current.set(person.id, node)
            return () => {
              boxesRef.current.delete(person.id)
            }
          }}
        />
      ))}
      <p>Focus is on: {focusedName}</p>
      {people.map(person => (
        <button key={person.id} onClick={() => focusBox(person.id)}>
          Go to {person.name}
        </button>
      ))}
      <button onClick={() => setPeople(people.slice(0, -1))}>Remove the last one</button>
      <button onClick={() => console.log('Boxes in the Map:', boxesRef.current.size)}>Count the boxes</button>
    </div>
  )
}
Boxes in the Map: 3
Boxes in the Map: 2

Run it. Press “Go to Ben”, and Ben’s box gets the focus. Press “Count the boxes”, then “Remove the last one”, then “Count the boxes” again. The Map has 3 boxes, then 2. The cleanup removed Cara’s.

.set(id, node) stores a value. .get(id) reads it. .delete(id) removes it. .size is how many are stored.

useRef(new Map()) makes a new empty Map on every render, and React ignores all but the first. An empty Map is cheap, so that’s fine here.

Common mistakes

Reading ref.current while rendering

You saw two examples: the render counter that counted by 2, and the “before” value that went wrong. Read and write refs in event handlers and Effects. If the page needs the value, use state.

Expecting a ref change to update the page

Setting ref.current doesn’t make React render. In our test, three clicks that changed a ref left the page at “Ref: 0”. If a value should show on the page, use useState.

Calling focus() while rendering

import { useRef } from 'react'

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null)
  inputRef.current.focus()
  return <input ref={inputRef} aria-label="Name" />
}

TypeScript catches this: 'inputRef.current' is possibly 'null'. The playground doesn’t check types, so press Run. The app stops with an error. Our test used the same JavaScript engine as Chrome, and the error said Cannot read properties of null (reading 'focus'). Other browsers may word it differently.

During the first render, the input is not on the page yet, so the ref is null. To focus a box when it first appears, use an Effect. Effects run after React puts the tags on the page:

import { useEffect, useRef } from 'react'

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    inputRef.current?.focus()
  }, [])

  return <input ref={inputRef} aria-label="Name" />
}

We checked: after loading, the box had focus, with Strict Mode on and off.

Using a variable outside the component

A variable outside the component keeps its value between renders too. So it can look like a ref. But every copy of the component shares it:

let clicks = 0

function ClickCounter({ name }: { name: string }) {
  function handleClick() {
    clicks = clicks + 1
    console.log(name, 'was clicked', clicks, 'times')
  }
  return <button onClick={handleClick}>{name}</button>
}

export default function App() {
  return (
    <div>
      <ClickCounter name="Left" />
      <ClickCounter name="Right" />
    </div>
  )
}
Left was clicked 1 times
Left was clicked 2 times
Right was clicked 3 times

Click “Left” twice, then “Right” once. “Right was clicked 3 times” is wrong. Right was clicked once. Both buttons add to the same clicks.

It even lasts longer than the component. We removed the app and added it again, from the same file. The next click on “Right” said 4. You can’t repeat this test with Run again: that loads the file fresh, so clicks starts at 0 again.

The fix is const clicks = useRef(0) inside ClickCounter. Each copy then gets its own ref. React’s docs say a ref is “local to each copy of your component”.

A ref callback with no cleanup

This version keeps the boxes in an array. It adds each one with push, and never takes any out:

import { useRef, useState } from 'react'

type Person = { id: number; name: string }

export default function App() {
  const [people, setPeople] = useState<Person[]>([
    { id: 1, name: 'Ana' },
    { id: 2, name: 'Ben' },
    { id: 3, name: 'Cara' },
  ])
  const [focusedName, setFocusedName] = useState('nobody')
  const boxesRef = useRef<HTMLInputElement[]>([])

  function focusBox(id: number) {
    boxesRef.current[id - 1]?.focus()
  }

  return (
    <div>
      {people.map(person => (
        <input
          key={person.id}
          aria-label={person.name}
          onFocus={() => setFocusedName(person.name)}
          ref={node => {
            if (node !== null) boxesRef.current.push(node)
          }}
        />
      ))}
      <p>Focus is on: {focusedName}</p>
      {people.map(person => (
        <button key={person.id} onClick={() => focusBox(person.id)}>
          Go to {person.name}
        </button>
      ))}
      <button onClick={() => setPeople(people.slice(0, -1))}>Remove the last one</button>
      <button onClick={() => console.log('Boxes in the list:', boxesRef.current.length)}>Count the boxes</button>
    </div>
  )
}
Boxes in the list: 6
Boxes in the list: 8

There are 3 boxes, but the list says 6. Strict Mode’s extra setup added each box twice. After “Remove the last one”, 2 boxes are left, but the list says 8. Each re-render made new callbacks, and they pushed the boxes again.

With Strict Mode off, we got 3, then 5. That’s still wrong. Strict Mode only made the bug show sooner. The fix is the Map from the last section, with a cleanup that removes each box.

Using a ref to skip the second setup of an Effect

In Part 11 you saw Strict Mode run an Effect’s setup, then its cleanup, then its setup again. You might want to use a ref to stop the second setup. This example pretends to connect to a chat server. The small connect function counts the open connections:

import { useEffect, useRef, useState } from 'react'

let openConnections = 0

function connect() {
  openConnections = openConnections + 1
  console.log('Connected. Open connections:', openConnections)
  return {
    close() {
      openConnections = openConnections - 1
      console.log('Closed. Open connections:', openConnections)
    },
  }
}

function Chat() {
  const connectedRef = useRef(false)

  useEffect(() => {
    if (connectedRef.current) return
    connectedRef.current = true
    connect()
  }, [])

  return <p>The chat is open.</p>
}

export default function App() {
  const [show, setShow] = useState(true)
  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Close the chat' : 'Open the chat'}</button>
      {show && <Chat />}
    </div>
  )
}
Connected. Open connections: 1
Connected. Open connections: 2

Run it. The first line shows only once, so the double run seems gone. Now press “Close the chat”, “Open the chat” and “Close the chat”. The chat is closed, but 2 connections are open. Each time the chat opens, one more connection is left behind.

The ref only covered up the bug. It didn’t fix it. React’s docs warn about exactly this: “Don’t use refs to prevent Effects from firing”. The fix is a cleanup that stops what the setup started:

import { useEffect, useState } from 'react'

let openConnections = 0

function connect() {
  openConnections = openConnections + 1
  console.log('Connected. Open connections:', openConnections)
  return {
    close() {
      openConnections = openConnections - 1
      console.log('Closed. Open connections:', openConnections)
    },
  }
}

function Chat() {
  useEffect(() => {
    const connection = connect()
    return () => connection.close()
  }, [])

  return <p>The chat is open.</p>
}

export default function App() {
  const [show, setShow] = useState(true)
  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Close the chat' : 'Open the chat'}</button>
      {show && <Chat />}
    </div>
  )
}
Connected. Open connections: 1
Closed. Open connections: 0
Connected. Open connections: 1
Closed. Open connections: 0
Connected. Open connections: 1
Closed. Open connections: 0
Connected. Open connections: 1
Closed. Open connections: 0

Now there are more lines in development, but the count always goes back to 0. Strict Mode’s extra setup and cleanup happen each time the chat opens. With Strict Mode off, we saw one “Connected” and one “Closed” for each open and close. Part 12 is all about cleanup functions like this.

Practice

Press Edit on any example above and try these.

  1. In “Try this first”, click the button 5 times. How many lines does the Console show in all?
  2. Add a Reset button to the stopwatch. It should stop the clock and set the seconds back to 0.
  3. In the ref callback example, press “Hide” once. What does the Console add? Then press “Show”. What does it add now?
  4. Fix the “Left” and “Right” counter with useRef. Click “Left” twice and “Right” once. What does the last line say?
Answers
  1. 7 lines. “App renders” twice at the start, because of Strict Mode, and then one line per click: clicks.current is 1 up to clicks.current is 5. Clicks don’t render App again.
  2. For example:
import { useRef, useState } from 'react'

export default function App() {
  const [seconds, setSeconds] = useState(0)
  const intervalRef = useRef<number | null>(null)

  function handleStart() {
    if (intervalRef.current !== null) return
    intervalRef.current = window.setInterval(() => {
      setSeconds(s => s + 1)
    }, 1000)
  }

  function handleStop() {
    if (intervalRef.current === null) return
    window.clearInterval(intervalRef.current)
    intervalRef.current = null
  }

  function handleReset() {
    handleStop()
    setSeconds(0)
  }

  return (
    <div>
      <p>Seconds: {seconds}</p>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
      <button onClick={handleReset}>Reset</button>
    </div>
  )
}
  1. “Hide” adds one line: Cleanup ran. “Show” adds three: React gave us the input: true, Cleanup ran, React gave us the input: true. Strict Mode tests the new input with an extra setup and cleanup.
  2. Put const clicks = useRef(0) inside ClickCounter. Use clicks.current in the handler. The last line says Right was clicked 1 times.

Interview questions

Try to answer each one out loud before you open the answer.

What does useRef give back, and what is it for?

It gives back a plain object, { current: initialValue }. React gives back the same object on every render. You can read and change current at any time, and changing it doesn’t cause a render.

It has two jobs. It keeps values that the page doesn’t show, like an interval’s id. And it holds DOM nodes, so you can call methods like focus().

A strong answer adds that a ref is local to each copy of the component. A variable outside the component is shared by every copy. It also knows that ref.current doesn’t belong in an Effect’s dependency array. Changing it doesn’t cause a render, so React never checks it again. We put [clicks.current] in the array and changed the ref 3 times: the Effect ran 0 more times. The ref object itself never changes, so React’s docs say you may leave it out.

When should you use a ref, and when state?

If the page shows the value, use state. Setting state makes React render again. If only event handlers and Effects need the value, a ref may be enough. Setting a ref doesn’t render, and the new value is there at once.

A strong answer names the trap. A ref shown on the page shows an old value. It changes only when something else makes React render. And it warns that refs are an escape hatch: most values belong in state.

Why shouldn’t you read or write ref.current during rendering?

React expects a component to give the same result for the same props, state and context. A ref is not one of those. React doesn’t know when it changes. So a component that reads it while rendering can show an old value. One that writes it while rendering can behave differently in development and production.

A strong answer gives an example. A render counter that adds one in the body shows 2 after loading in Strict Mode. Without Strict Mode, it shows 1. It also knows the exception: filling a ref once, with if (ref.current === null) ref.current = ....

When is a DOM ref’s current empty?

During the first render, because the tag is not on the page yet. After the tag leaves the page, because React sets it back to null. And when the tag with that ref is not rendered at all. React sets the ref after it puts the tag on the page, in the commit step. So event handlers and Effects can use it.

A strong answer mentions TypeScript. useRef<HTMLInputElement>(null) makes current have the type HTMLInputElement | null. So you check it, or use ?..

How do you give a parent a ref to an input inside your own component in React 19?

Take ref as a prop and pass it to the <input>: function NameBox({ ref }) { return <input ref={ref} /> }. In React 19, ref is a normal prop for function components.

A strong answer knows forwardRef. It was needed before React 19, and still works. React’s docs say it is no longer necessary and will be deprecated. A strong answer also names useImperativeHandle. With it, the parent gets a small object, like { focus }, instead of the whole DOM node.

What is a ref callback, and what changed in React 19?

It’s a function passed to ref. React calls it with the DOM node when the tag goes on the page. In React 19, it can return a cleanup function, which React calls when the tag leaves. Before, React called the callback again with null. That still happens if you return nothing, but the docs say this will be removed.

A strong answer adds two details. In development, Strict Mode runs an extra setup and cleanup, to catch a missing cleanup. And a callback written inside the JSX is a new function on every render. So React runs the old cleanup and the new callback on each re-render. A strong answer also knows the list pattern: a ref that holds a Map, filled by ref callbacks. And it knows a TypeScript detail. React 19’s types refuse a ref callback that returns a value, like ref={n => (x = n)}. Write ref={n => { x = n }} instead.

Why not keep an interval’s id in a normal let variable in the component?

The component’s body runs again on every render, so the variable starts over. A stopwatch re-renders every second. So by the time you press Stop, the handler sees a fresh null, and the interval keeps running. A ref keeps the id through every render.

A strong answer adds that state would also keep it. But each change would cause a render that the page doesn’t need. And the interval should be stopped when the component leaves the page, with an Effect’s cleanup.

Sources

  • useRef, react.dev: what useRef returns, “React is not aware of when you change it because a ref is a plain JavaScript object”, “Do not write or read ref.current during rendering, except for initialization”, filling a ref once, a second ref object made and thrown away in Strict Mode, and “local to each copy of your component”.
  • Referencing Values with Refs, react.dev: refs vs state, the stopwatch, refs as an “escape hatch”, and that a ref changes at once.
  • Manipulating the DOM with Refs, react.dev: ref on a tag, when React sets and clears refs (the commit), the Map of refs, ref as a prop, useImperativeHandle, and “can lead to inconsistent visual results or crashes”.
  • Common components, react.dev: ref callbacks, their cleanup, being called with null, “will be removed in a future version”, and “React will run one extra development-only setup+cleanup cycle before the first real setup”.
  • StrictMode, react.dev: why ref callbacks run an extra time in development.
  • forwardRef, react.dev: “In React 19, forwardRef is no longer necessary. Pass ref as a prop instead.” and “will be deprecated in a future release”.
  • useImperativeHandle, react.dev: choosing what the parent’s ref gets.
  • React v19, react.dev blog: ref as a prop, and cleanup functions for ref callbacks.
  • Synchronizing with Effects, react.dev: “Don’t use refs to prevent Effects from firing”, and why a cleanup is the fix.
  • Lifecycle of Reactive Effects, react.dev: ref.current can’t be a dependency, and the ref object is stable, so “you may omit them”.
  • useState, react.dev: storing information from previous renders in state.
  • MDN: setInterval (the interval id), clearInterval, focus(), scrollIntoView() and getBoundingClientRect().
  • The logs, counts, error text and “we checked” results above come from running React 19.3.0 and TypeScript 7.0.2 for this post.
  • This part follows the useRef kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.