cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Referencing values with refs

Learn React's useRef: remember a value between renders without rendering again, build a stopwatch, and know when to choose a ref over state.

By the end, you can
  • 01
    Keep a value with useRefMake a ref, read and change ref.current in event handlers, and explain why changing it doesn't render again.
  • 02
    Choose between a ref and statePut what the screen shows in state, and timer ids, outside objects, and latest values in refs, as in a stopwatch.
  • 03
    Keep rendering pureKeep ref.current out of rendering, except to fill an empty ref once, and say why React needs that.

A click count the screen doesn't need

A shop wants to know how often people press Add to cart, to send the number to its analytics later. The number never appears on the page. So far, the only way you've had to make a component remember something between renders is state, and every state change makes React render again. Here is another way. Click the button three times, then look at the console under the result.

Counting clicks without showing themPop out in the code editor (opens in a new tab)JSX
import { useRef } from "react"; export default function App() {  const clicks = useRef(0);   function handleClick() {    clicks.current = clicks.current + 1;    console.log(`Add to cart was clicked ${clicks.current} times`);  }   return <button onClick={handleClick}>Add to cart</button>;}
ResultLoading…

Line 4 asks React for a ref: useRef(0) returns an object with one property, current, which starts at 0. Line 7 adds one to clicks.current, and line 8 prints it. The console shows Add to cart was clicked 1 times, then 2, then 3: the ref remembered the count from click to click. The button never changed, and React never rendered App again, because nothing asked it to.

Three function cards, App render 1, render 2 and render 3, each with an arrow to the same box named clicks, which holds a card reading current: 3.App(): render 1App(): render 2App(): render 3current: 3clicksone object, kept by React
Each render calls App again, but useRef hands every call the same object. After three clicks, clicks.current is 3, whichever render reads it.
Real-life analogyA tally in your pocket

A shopkeeper keeps prices on a board that customers read, and rewrites the board when a price changes. They also keep a tally of visitors on a card in their pocket. Adding a mark to the tally doesn't touch the board, and no customer ever sees it, but the shopkeeper can check it any time.

In real life: A shop's price board, which customers read
In React: The screen, drawn from state
In real life: Changing a price on the board, so everyone sees it
In React: Setting state, which makes React render again
In real life: A tally of visitors on a card in your pocket
In React: A ref: a value only your code reads
In real life: Adding a mark doesn't change the board
In React: Changing ref.current doesn't render anything
The main idea

A ref is a value your component remembers between renders without rendering again when it changes. useRef(initialValue) gives you an object, { current: initialValue }, and React gives you the same object on every render. You read and change ref.current yourself, in event handlers and effects.

useRef: one object that lasts between renders

useRef is a hook, like useState: import it from react and call it at the top of your component. It takes the starting value and returns a plain JavaScript object with one property, current. On the first render, React makes that object. On every later render, React returns the very same object, and ignores the starting value you pass.

Always pass a starting value, even when you have none yet: then pass null, as the tea timer below does. In TypeScript, React 19's types require it, and useRef() with nothing in the brackets is an error, Expected 1 arguments, but got 0. Older code sometimes left it out.

Two things make a ref different from state. Changing it doesn't render, as you just saw. And it changes right away. State is a snapshot: inside one render, it keeps the value that render started with. Click this button once and read the console:

State keeps its snapshot; a ref changes at oncePop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; export default function App() {  const [quantity, setQuantity] = useState(1);  const latest = useRef(1);   function handleClick() {    setQuantity(quantity + 1);    latest.current = latest.current + 1;    console.log("quantity:", quantity, "latest.current:", latest.current);  }   return <button onClick={handleClick}>Tea × {quantity}</button>;}
ResultLoading…

Line 8 asks React to make quantity 2 in the next render. Line 9 makes latest.current 2 right now. Line 10 prints quantity: 1 latest.current: 2: this render's quantity is still 1, but the ref already holds 2. The button shows Tea × 2 after the new render. Click again and the console says quantity: 2 latest.current: 3.

You could build useRef from useState. Here is a small model, written for this lesson, not React's real code:

A model of useRef, built from useStateJSX
import { useState } from "react";

function useRef(initialValue) {
  const [ref] = useState({ current: initialValue });
  return ref;
}

Line 4 keeps an object in state and never calls the setter, so React never renders because of it, and returns the same object every time. Changing ref.current changes a property of that object, which React doesn't watch. (React has its own useRef, without the extra setter, but it behaves like this.)

Changing a ref doesn't render again

What if you show a ref on the screen? Here are two click counters, one in state and one in a ref. The ref button reads refClicks.current while rendering, on purpose, to show what goes wrong. Line 6 prints Render each time React calls App. In development, StrictMode calls it twice for each render, so the console shows each Render with a 2 beside it.

A counter in state and a counter in a refPop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; export default function App() {  const [stateClicks, setStateClicks] = useState(0);  const refClicks = useRef(0);  console.log("Render");   return (    <>      <button onClick={() => setStateClicks(stateClicks + 1)}>State: {stateClicks}</button>{" "}      <button onClick={() => (refClicks.current = refClicks.current + 1)}>Ref: {refClicks.current}</button>    </>  );}
ResultLoading…

Click Ref: 0 three times. The button keeps saying Ref: 0, and no new Render appears: line 11 changed refClicks.current to 3, but nothing asked React to render. Now click State: 0. The setter on line 10 makes React render App again, and this render reads both values: the buttons say State: 1 and Ref: 3. The ref had all three clicks; the screen was behind.

Left, a click with state: setStateClicks(1), then React renders again, then the screen shows State: 1. Right, a click with a ref: refClicks.current = 1, then no render, then the screen still shows Ref: 0.Click with statesetStateClicks(1)React renders againscreen: State: 1Click with a refrefClicks.current = 1no renderscreen: Ref: 0the screen is behind
The setter asks React for a new render, so the screen catches up. Changing ref.current asks for nothing: the value changes, and the screen stays as it was until something else renders.

Try it with the counters side by side. Each one counts the renders React commits. Click Ref a few times, then press Render the ref counter again.

Playground: does the click render again?
With useState0 renders
With useRef0 renders

In refClicks.current now: 0

Try it yourself

Click each button a few times. Each side counts how many times React rendered its component.

Both buttons are the ones from the example above, each in its own component. An effect counts every render React commits; StrictMode's extra development runs aren't counted. Render the ref counter again gives it a new prop, so React renders it. Reset starts both at 0.

The state side adds one render for every click. The ref side stays at 1 render however often you click, while refClicks.current counts up below it. When you make it render again, it reads the ref and catches up. That's why a value the screen shows belongs in state.

Refs and state side by side

Refs and state both remember a value between renders, and both belong to one use of a component on the page. The difference is what happens when the value changes. How to read the table: each row asks one question, with the answer for a ref and for state.

A ref or state?
QuestionA refState
How do you make it?useRef(initialValue) returns { current: initialValue }.useState(initialValue) returns [value, setValue].
What happens when it changes?Nothing: React doesn't render again.React renders the component again.
How do you change it?Assign to ref.current directly.Call the setter. Never change the value itself.
When can you see the new value?Right away, on the next line.In the next render. This render keeps its snapshot.
Can you read it while rendering?No, except to fill an empty ref once.Yes, any time.
What is it for?Values only your handlers need: timer ids, outside objects, the latest value, DOM elements.Everything the screen shows.

The rule of thumb: if the screen shows it, use state. If only your event handlers need it, like a timer's id or a count you send somewhere, a ref is enough, and it saves a render.

A stopwatch: the time in state, the timer in a ref

A tea timer needs both. The time it shows has to be state, so the screen updates. To stop it, the code needs the id that setInterval returns, and the screen never shows that id. Press Start, wait, then Stop.

A tea timer with a ref for the interval idPop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; export default function App() {  const [startTime, setStartTime] = useState(null);  const [now, setNow] = useState(null);  const intervalRef = useRef(null);   function handleStart() {    setStartTime(Date.now());    setNow(Date.now());    clearInterval(intervalRef.current);    intervalRef.current = setInterval(() => {      setNow(Date.now());    }, 100);  }   function handleStop() {    clearInterval(intervalRef.current);  }   let seconds = 0;  if (startTime !== null && now !== null) {    seconds = (now - startTime) / 1000;  }   return (    <>      <h2>Tea steeping: {seconds.toFixed(1)} s</h2>      <button onClick={handleStart}>Start</button> <button onClick={handleStop}>Stop</button>    </>  );}
ResultLoading…
  1. Lines 4 and 5 keep the start time and the time now in state, as numbers of milliseconds from Date.now(). Line 6 keeps the interval id in a ref, starting as null.
  2. handleStart sets both times on lines 9 and 10. Line 11 stops an interval that's already running, so pressing Start twice doesn't leave two of them. Lines 12 to 14 start a new interval that sets now every 100 milliseconds, and keep its id in intervalRef.current.
  3. handleStop on line 18 reads the id and stops the interval.
  4. Lines 21 to 24 work out the seconds during rendering, from state only, and line 28 shows them.
A box named intervalRef holds a card reading current: the id. An arrow from a function card, handleStart, stores the id in it. An arrow from the box leads to a function card, handleStop, which reads it. Below, three render cards, one every 0.1 seconds, all sharing the same box.handleStartstores the idcurrent: idintervalRefhandleStopreads the same idrender at 0.1 srender at 0.2 srender at 0.3 seach render is a new call of App; intervalRef stays the same object
handleStart stores the id that setInterval returns in intervalRef.current. The renders every 0.1 seconds don't touch it, so handleStop finds the same id and stops the interval.

Why not a plain variable? Here is the same timer with let intervalId = null; on line 6 instead of the ref. Press Start, then Stop:

The timer with a plain variable (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [startTime, setStartTime] = useState(null);  const [now, setNow] = useState(null);  let intervalId = null;   function handleStart() {    setStartTime(Date.now());    setNow(Date.now());    clearInterval(intervalId);    intervalId = setInterval(() => {      setNow(Date.now());    }, 100);  }   function handleStop() {    clearInterval(intervalId);  }   let seconds = 0;  if (startTime !== null && now !== null) {    seconds = (now - startTime) / 1000;  }   return (    <>      <h2>Tea steeping: {seconds.toFixed(1)} s</h2>      <button onClick={handleStart}>Start</button> <button onClick={handleStop}>Stop</button>    </>  );}
ResultLoading…

It keeps counting. Every render calls App again, and each call runs line 6 again and makes a new intervalId, set to null. handleStart stored the id in the variable of the render it belonged to. But its setStartTime made React render again at once, and that render made new functions with their own intervalId, so the handleStop you press reads null and stops nothing. (Each function sees the variables of the call that made it: closures.) Press Run again to stop it. State would work, but setting the id would render for nothing. A ref keeps one value across all the renders, and changing it costs nothing.

Keep refs out of rendering

Rendering should be pure: the same props, state, and context give the same JSX, and calling the component has no other effect. React relies on it. In development, StrictMode calls your component twice for each render to catch mistakes, and React can start a render and throw it away. A ref read or written while rendering breaks this. Here is a render counter written that way, a mistake on purpose:

A render counter in a ref (a mistake on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; export default function App() {  const [quantity, setQuantity] = useState(1);  const renders = useRef(0);  renders.current = renders.current + 1;   return (    <>      <button onClick={() => setQuantity(quantity + 1)}>Tea × {quantity}</button>      <p>Renders: {renders.current}</p>    </>  );}
ResultLoading…

It says Renders: 2 before you click anything, and each click adds 2. Line 6 runs every time React calls the component, and StrictMode calls it twice, both times with the same ref. So the number counts calls, not renders, and in a production build it would count differently. Reading ref.current in JSX, as line 11 does, has the other problem you saw: the screen only shows it when something else renders.

The rule: read and write refs in event handlers and effects, never while rendering.

Where a ref may be read and writtenJSX
function App() {
  const intervalRef = useRef(null);

  // Not here: this runs while React renders.
  // intervalRef.current = 123;
  // return <p>{intervalRef.current}</p>;

  function handleStop() {
    // Here: event handlers run after a click, not while rendering.
    clearInterval(intervalRef.current);
  }
  // Effects are fine too; they run after React has updated the screen.
}

Lines 4 to 6 are what not to do: they run while React renders. Line 10 is inside an event handler, which runs after a click, so it's fine. Effects, covered in Synchronizing with effects, run after React updates the screen, so they may use refs too.

There is one exception: filling a ref the first time. useRef(createTracker("tea-page")) calls createTracker on every render, though React keeps only the first result. Click this button twice and count the Tracker created lines in the console:

An object made on every render (wasteful on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; function createTracker(page) {  console.log("Tracker created for", page);  return {    track(event) {      console.log(page, event);    },  };} export default function App() {  const [quantity, setQuantity] = useState(1);  const tracker = useRef(createTracker("tea-page"));   function handleClick() {    setQuantity(quantity + 1);    tracker.current.track("add one");  }   return <button onClick={handleClick}>Tea × {quantity}</button>;}
ResultLoading…

Line 14 calls createTracker each time React calls App, and throws the new tracker away. Instead, start the ref empty and fill it once:

import { useRef, useState } from "react"; function createTracker(page) {  console.log("Tracker created for", page);  return {    track(event) {      console.log(page, event);    },  };} export default function App() {  const [quantity, setQuantity] = useState(1);  const tracker = useRef(null);  if (tracker.current === null) {    tracker.current = createTracker("tea-page");  }   function handleClick() {    setQuantity(quantity + 1);    tracker.current.track("add one");  }   return <button onClick={handleClick}>Tea × {quantity}</button>;}
ResultLoading…

Lines 15 to 17 read and write tracker.current while rendering, but only while it's still null. The console shows Tracker created for tea-page once, then tea-page add one for each click. The result is always the same, and it only happens on the first render, so the component stays predictable. This is called lazy initialization: making a value only when it's first needed.

An escape hatch: the latest message for a timer

React calls refs an escape hatch: a way to step outside its usual flow, where the screen is worked out from props and state. Use one when you need to hold something React doesn't draw: a timer's id, an object from another library, or, in the next lesson, a DOM element. Here is a chat that waits three seconds before it sends, so you can undo. Type a message, press Send, and keep typing before the three seconds are up.

Send in 3 seconds, with UndoPop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; export default function App() {  const [text, setText] = useState("");  const [status, setStatus] = useState("");  const timeoutRef = useRef(null);  const latestText = useRef("");   function handleChange(e) {    setText(e.target.value);    latestText.current = e.target.value;  }   function handleSend() {    setStatus("Sending in 3 seconds…");    clearTimeout(timeoutRef.current);    timeoutRef.current = setTimeout(() => {      setStatus(`Sent: "${latestText.current}"`);    }, 3000);  }   function handleUndo() {    clearTimeout(timeoutRef.current);    setStatus("Not sent.");  }   return (    <>      <input value={text} onChange={handleChange} aria-label="Message" />{" "}      <button onClick={handleSend}>Send</button> <button onClick={handleUndo}>Undo</button>      <p>{status}</p>    </>  );}
ResultLoading…

The status says what you had typed when the three seconds ran out, not when you pressed Send. Line 6 keeps the timeout's id, so handleUndo can cancel it on line 23. Line 7 keeps the latest text: line 11 updates it on every keystroke, next to the state on line 10. The timeout on line 18 reads latestText.current, the value now, not a snapshot.

Press Edit & run and change line 18 to read text instead of latestText.current. Now it sends what you had typed when you pressed Send: the timeout was made by that render, and it sees that render's text, as State as a snapshot explains. Neither is wrong; choose the one you mean. The ref is for when you mean "the newest value".

A ref or state?
  • The text in a search box
  • The id that setInterval returns, kept to stop it later
  • Whether a dropdown menu is open
  • How many times a button was clicked, sent only to analytics
  • The items in a shopping cart
  • The latest typed message, read by a timeout later
  • The page number shown under a list
  • A chart object from another library, made once and kept
Try it yourself
0 of 8 correct

Sort each value: does it belong in a ref or in state?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Changing ref.current updates the screen." No. React doesn't know it changed. The screen only shows the new value when something else makes the component render.
  • "A ref is the same as a variable inside the component." No. A let inside the component starts over on every render. A ref keeps its value from render to render.
  • "A ref is the same as a variable outside the component." No. A variable outside is shared by every use of the component on the page. Each use gets its own ref.
  • "useRef(initialValue) resets the ref each render." No. React uses the starting value only on the first render and ignores it after that.
  • "Refs are only for DOM elements." No. A ref can hold any value: a number, an id, an object. DOM elements are one common use, in the next lesson.
  • "Use a ref to avoid renders and make the app faster." Only for values the screen doesn't show. Anything the screen shows must be state, or the screen falls behind.

Practice exercises

Exercise 1 · Warm-upPredict the button after three clicks

You click the button three times. What does it say? Answer first, then press Edit & run to check.

import { useRef } from "react";

export default function App() {
  const clicks = useRef(0);

  return (
    <button onClick={() => (clicks.current = clicks.current + 1)}>
      Clicked {clicks.current} times
    </button>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the log

    You click the button once. What does the console show?

    import { useRef, useState } from "react";
    
    export default function App() {
      const [count, setCount] = useState(0);
      const ref = useRef(0);
    
      function handleClick() {
        setCount(count + 1);
        ref.current = ref.current + 1;
        console.log(count, ref.current);
      }
    
      return <button onClick={handleClick}>Click me</button>;
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMake Cancel cancel

      Press Boil, then Cancel. Three seconds later it still says it's tea time. Fix it with a ref, then type what the message says three seconds after Boil and Cancel.

      import { useState } from "react"; export default function App() {  const [message, setMessage] = useState("Kettle is off.");  let timeoutId = null;   function handleBoil() {    setMessage("Boiling…");    timeoutId = setTimeout(() => setMessage("Tea time!"), 3000);  }   function handleCancel() {    clearTimeout(timeoutId);    setMessage("Cancelled.");  }   return (    <>      <p>{message}</p>      <button onClick={handleBoil}>Boil</button> <button onClick={handleCancel}>Cancel</button>    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeShow the likes

        Clicking Like does nothing you can see. Fix it, then type what the page says after two clicks.

        import { useRef } from "react"; export default function App() {  const likes = useRef(12);   function handleLike() {    likes.current = likes.current + 1;  }   return (    <>      <p>{likes.current} likes</p>      <button onClick={handleLike}>Like</button>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMake the tracker once

          Each click makes a new tracker that React throws away: the console shows Tracker created again and again. Make it create the tracker only once.

          import { useRef, useState } from "react"; function createTracker(page) {  console.log("Tracker created for", page);  return {    track(event) {      console.log(page, event);    },  };} export default function App() {  const [quantity, setQuantity] = useState(1);  const tracker = useRef(createTracker("tea-page"));   function handleClick() {    setQuantity(quantity + 1);    tracker.current.track("add one");  }   return <button onClick={handleClick}>Tea × {quantity}</button>;}
          ResultLoading…
            Exercise 6 · ChallengeIgnore a double click on Pay

            A quick double click on Pay $4 pays twice. Make the button ignore any click within one second of the last payment. Then double-click it: how many payments does it say?

            import { useState } from "react"; export default function App() {  const [payments, setPayments] = useState(0);   function handlePay() {    setPayments(payments + 1);  }   return (    <>      <button onClick={handlePay}>Pay $4</button>      <p>Payments sent: {payments}</p>    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The refs quiz · 7 questionsScore: first tries count
              1. Question 1 of 7You click this button three times. What does it say?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useRef } from "react";
                
                export default function App() {
                  const clicks = useRef(0);
                
                  return (
                    <button onClick={() => (clicks.current = clicks.current + 1)}>
                      Clicked {clicks.current} times
                    </button>
                  );
                }

                Choose an answer to see the explanation.

              2. Question 2 of 7You click this button once. What does line 10 print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { useRef, useState } from "react";
                
                export default function App() {
                  const [count, setCount] = useState(0);
                  const ref = useRef(0);
                
                  function handleClick() {
                    setCount(count + 1);
                    ref.current = ref.current + 1;
                    console.log(count, ref.current);
                  }
                
                  return <button onClick={handleClick}>Click me</button>;
                }

                Choose an answer to see the explanation.

              3. Question 3 of 7On the second render of this component, what is box?

                Read the code, then predictJSX
                function App() {
                  const box = useRef({ cups: 0 });
                  // React renders App a second time.
                }
                // On the second render, what is box?

                Choose an answer to see the explanation.

              4. Question 4 of 7Which value belongs in a ref rather than in state?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why doesn't Stop stop this timer?

                Read the code, then predictJSX
                function App() {
                  const [now, setNow] = useState(Date.now());
                  let intervalId = null;
                
                  function handleStart() {
                    intervalId = setInterval(() => setNow(Date.now()), 100);
                  }
                
                  function handleStop() {
                    clearInterval(intervalId);
                  }
                  // …
                }

                Choose an answer to see the explanation.

              6. Question 6 of 7When may a component write ref.current while it renders?

                Choose an answer to see the explanation.

              7. Question 7 of 7In development, a render counter written as renders.current += 1 in the component body shows 2 before any click. Why?

                Choose an answer to see the explanation.

              Key takeaways

              • useRef(initialValue) returns { current: initialValue }, the same object on every render.
              • Changing ref.current doesn't render again, and the new value is there at once.
              • If the screen shows a value, it's state. If only your handlers need it, a ref is enough.
              • Keep timer ids in refs: a plain variable starts over on every render.
              • Read and write refs in event handlers and effects, not while rendering, except to fill an empty ref once.
              • Refs are an escape hatch for things React doesn't draw: ids, outside objects, the latest value, and DOM elements.

              A ref remembers a value between renders without rendering again.
              Use state for what the screen shows, and a ref for what only your code needs.

              Coming next: Manipulating the DOM with refs. It puts a DOM element in a ref, to focus a box or scroll to an item.

              CompleteFrontend Clear concepts. Working examples.